Accidenti, ci sono alcune strane idee sbagliate su ciò che OCP e LSP e alcune sono dovute alla mancata corrispondenza di alcune terminologie ed esempi confusi. Entrambi i principi sono solo la "stessa cosa" se li implementi allo stesso modo. I modelli di solito seguono i principi in un modo o nell'altro con poche eccezioni.
Le differenze verranno spiegate più in basso, ma prima facciamo un tuffo nei principi stessi:
Principio aperto-chiuso (OCP)
Secondo lo zio Bob :
Dovresti essere in grado di estendere un comportamento delle classi, senza modificarlo.
Si noti che la parola extension in questo caso non significa necessariamente che è necessario sottoclassare la classe effettiva che necessita del nuovo comportamento. Vedi come ho accennato alla prima discrepanza della terminologia? La parola chiave extend
significa solo sottoclasse in Java, ma i principi sono più vecchi di Java.
L'originale venne da Bertrand Meyer nel 1988:
Le entità software (classi, moduli, funzioni, ecc.) Devono essere aperte per l'estensione, ma chiuse per modifica.
Qui è molto più chiaro che il principio viene applicato alle entità software . Un cattivo esempio potrebbe essere l'override dell'entità software mentre si modifica completamente il codice anziché fornire un punto di estensione. Il comportamento dell'entità software stessa dovrebbe essere estensibile e un buon esempio di ciò è l'implementazione del modello di strategia (perché è il più semplice da mostrare del gruppo IMHO dei modelli di GoF):
// Context is closed for modifications. Meaning you are
// not supposed to change the code here.
public class Context {
// Context is however open for extension through
// this private field
private IBehavior behavior;
// The context calls the behavior in this public
// method. If you want to change this you need
// to implement it in the IBehavior object
public void doStuff() {
if (this.behavior != null)
this.behavior.doStuff();
}
// You can dynamically set a new behavior at will
public void setBehavior(IBehavior behavior) {
this.behavior = behavior;
}
}
// The extension point looks like this and can be
// subclassed/implemented
public interface IBehavior {
public void doStuff();
}
Nell'esempio sopra Context
è bloccato per ulteriori modifiche. La maggior parte dei programmatori vorrebbe probabilmente sottoclassare la classe per estenderla, ma qui non lo facciamo perché presume che il suo comportamento possa essere modificato attraverso qualsiasi cosa che implementi l' IBehavior
interfaccia.
Vale a dire la classe di contesto è chiusa per modifica ma aperta per estensione . Segue in realtà un altro principio di base perché stiamo mettendo il comportamento con la composizione dell'oggetto invece dell'ereditarietà:
"Favorire la" composizione dell'oggetto "rispetto all'ereditarietà di classe ." (Gang of Four 1995: 20)
Lascerò che il lettore legga questo principio in quanto non rientra nell'ambito di questa domanda. Per continuare con l'esempio, supponiamo di avere le seguenti implementazioni dell'interfaccia IBehavior:
public class HelloWorldBehavior implements IBehavior {
public void doStuff() {
System.println("Hello world!");
}
}
public class GoodByeBehavior implements IBehavior {
public void doStuff() {
System.out.println("Good bye cruel world!");
}
}
Usando questo modello possiamo modificare il comportamento del contesto in fase di esecuzione, attraverso il setBehavior
metodo come punto di estensione.
// in your main method
Context c = new Context();
c.setBehavior(new HelloWorldBehavior());
c.doStuff();
// prints out "Hello world!"
c.setBehavior(new GoodByeBehavior());
c.doStuff();
// prints out "Good bye cruel world!"
Quindi, ogni volta che vuoi estendere la classe di contesto "chiusa", esegui la sottoclasse della sua dipendenza collaborativa "aperta". Questo chiaramente non è la stessa cosa della sottoclasse del contesto stesso, ma è OCP. Anche LSP non fa menzione di questo.
Estensione con Mixin anziché Ereditarietà
Esistono altri modi per eseguire OCP oltre alla sottoclasse. Un modo è mantenere le tue classi aperte per l'estensione attraverso l'uso di mixin . Ciò è utile, ad esempio, in linguaggi basati su prototipi piuttosto che su classi. L'idea è quella di modificare un oggetto dinamico con più metodi o attributi secondo necessità, in altre parole oggetti che si fondono o "si mescolano" con altri oggetti.
Ecco un esempio javascript di un mixin che rende un semplice modello HTML per ancore:
// The mixin, provides a template for anchor HTML elements, i.e. <a>
var LinkMixin = {
render: function() {
return '<a href="' + this.link +'">'
+ this.content
+ '</a>;
}
}
// Constructor for a youtube link
var YoutubeLink = function(content, youtubeId) {
this.content = content;
this.setLink(this.youtubeId);
};
// Methods are added to the prototype
YoutubeLink.prototype = {
setLink: function(youtubeid) {
this.link = 'http://www.youtube.com/watch?v=' + youtubeid;
}
};
// Extend YoutubeLink prototype with the LinkMixin using
// underscore/lodash extend
_.extend(YoutubeLink.protoype, LinkMixin);
// When used:
var ytLink = new YoutubeLink("Cool Movie!", "idOaZpX8lnA");
console.log(ytLink.render());
// will output:
// <a href="http://www.youtube.com/watch?=vidOaZpX8lnA">Cool Movie!</a>
L'idea è di estendere gli oggetti in modo dinamico e il vantaggio è che gli oggetti possono condividere metodi anche se si trovano in domini completamente diversi. Nel caso sopra puoi facilmente creare altri tipi di ancore html estendendo la tua implementazione specifica con LinkMixin
.
In termini di OCP, i "mixin" sono estensioni. Nell'esempio sopra YoutubeLink
è la nostra entità software che è chiusa per modifica, ma aperta per estensioni attraverso l'uso di mixin. La gerarchia degli oggetti è appiattita, il che rende impossibile verificare la presenza di tipi. Tuttavia, questa non è davvero una brutta cosa, e spiegherò più avanti che il controllo dei tipi è generalmente una cattiva idea e rompe l'idea con il polimorfismo.
Si noti che è possibile eseguire l'ereditarietà multipla con questo metodo poiché la maggior parte delle extend
implementazioni può combinare più oggetti:
_.extend(MyClass, Mixin1, Mixin2 /* [, ...] */);
L'unica cosa da tenere a mente è non scontrarsi con i nomi, vale a dire che i mixin definiscono lo stesso nome di alcuni attributi o metodi in quanto verranno sovrascritti. Nella mia umile esperienza questo non è un problema e se succede è un'indicazione di un design difettoso.
Principio di sostituzione di Liskov (LSP)
Lo zio Bob lo definisce semplicemente:
Le classi derivate devono essere sostituibili per le loro classi di base.
Questo principio è vecchio, infatti la definizione di zio Bob non differenzia i principi in quanto ciò rende LSP ancora strettamente correlato a OCP dal fatto che, nell'esempio di strategia sopra, viene utilizzato lo stesso supertipo ( IBehavior
). Vediamo quindi la sua definizione originale di Barbara Liskov e vediamo se possiamo scoprire qualcos'altro su questo principio che assomiglia a un teorema matematico:
Ciò che si desidera qui è qualcosa di simile alla seguente proprietà di sostituzione: Se per ogni oggetto o1
di tipo S
esiste un oggetto o2
di tipo T
tale che per tutti i programmi P
definiti in termini di T
, il comportamento di P
rimane invariato quando o1
viene sostituito, o2
allora S
è un sottotipo di T
.
Facciamo spallucce per un po ', notate perché non menziona affatto le lezioni. In JavaScript puoi effettivamente seguire LSP anche se non è esplicitamente basato sulla classe. Se il tuo programma ha un elenco di almeno un paio di oggetti JavaScript che:
- deve essere calcolato allo stesso modo,
- hanno lo stesso comportamento e
- altrimenti sono in qualche modo completamente diversi
... quindi si considera che gli oggetti abbiano lo stesso "tipo" e non importa davvero per il programma. Questo è essenzialmente polimorfismo . In senso generico; non dovresti aver bisogno di conoscere il sottotipo reale se stai usando la sua interfaccia. OCP non dice nulla di esplicito al riguardo. In realtà individua anche un errore di progettazione che la maggior parte dei programmatori principianti fa:
Ogni volta che senti il bisogno di controllare il sottotipo di un oggetto, molto probabilmente lo stai facendo SBAGLIATO.
Va bene, quindi potrebbe non essere sempre sbagliato, ma se hai la voglia di fare qualche tipo di controllo con instanceof
o enum, potresti fare il programma un po 'più contorto per te di quanto dovrebbe essere. Ma questo non è sempre il caso; gli hack rapidi e sporchi per far funzionare le cose sono una buona concessione da prendere in considerazione se la soluzione è abbastanza piccola e se pratichi il refactoring senza pietà , potrebbe migliorare una volta che le modifiche lo richiedono.
Esistono modi per aggirare questo "errore di progettazione", a seconda del problema reale:
- La superclasse non chiama i prerequisiti, costringendo invece il chiamante a farlo.
- Alla super classe manca un metodo generico di cui il chiamante ha bisogno.
Entrambi sono "errori" comuni nella progettazione del codice. Esistono un paio di diversi refactoring che è possibile eseguire, come il metodo pull-up o il refactoring di uno schema come lo schema Visitatore .
In realtà mi piace molto il modello Visitor in quanto può occuparsi di grandi spaghetti if-statement ed è più semplice da implementare rispetto a quello che penseresti sul codice esistente. Supponiamo che abbiamo il seguente contesto:
public class Context {
public void doStuff(string query) {
// outcome no. 1
if (query.Equals("Hello")) {
System.out.println("Hello world!");
}
// outcome no. 2
else if (query.Equals("Bye")) {
System.out.println("Good bye cruel world!");
}
// a change request may require another outcome...
}
}
// usage:
Context c = new Context();
c.doStuff("Hello");
// prints "Hello world"
c.doStuff("Bye");
// prints "Bye"
I risultati dell'istruzione if possono essere tradotti nei propri visitatori poiché ciascuno dipende da alcune decisioni e da alcuni codici da eseguire. Possiamo estrarre questi in questo modo:
public interface IVisitor {
public bool canDo(string query);
public void doStuff();
}
// outcome 1
public class HelloVisitor implements IVisitor {
public bool canDo(string query) {
return query.Equals("Hello");
}
public void doStuff() {
System.out.println("Hello World");
}
}
// outcome 2
public class ByeVisitor implements IVisitor {
public bool canDo(string query) {
return query.Equals("Bye");
}
public void doStuff() {
System.out.println("Good bye cruel world");
}
}
A questo punto, se il programmatore non fosse a conoscenza del modello Visitor, implementerebbe invece la classe Context per verificare se è di un certo tipo. Poiché le classi Visitor hanno un canDo
metodo booleano , l'implementatore può usare quella chiamata al metodo per determinare se è l'oggetto giusto per fare il lavoro. La classe di contesto può utilizzare tutti i visitatori (e aggiungerne di nuovi) in questo modo:
public class Context {
private ArrayList<IVisitor> visitors = new ArrayList<IVisitor>();
public Context() {
visitors.add(new HelloVisitor());
visitors.add(new ByeVisitor());
}
// instead of if-statements, go through all visitors
// and use the canDo method to determine if the
// visitor object is the right one to "visit"
public void doStuff(string query) {
for(IVisitor visitor : visitors) {
if (visitor.canDo(query)) {
visitor.doStuff();
break;
// or return... it depends if you have logic
// after this foreach loop
}
}
}
// dynamically adds new visitors
public void addVisitor(IVisitor visitor) {
if (visitor != null)
visitors.add(visitor);
}
}
Entrambi i modelli seguono OCP e LSP, tuttavia entrambi identificano cose diverse su di essi. Quindi, come appare il codice se viola uno dei principi?
Violare un principio ma seguire l'altro
Ci sono modi per violare uno dei principi ma è comunque necessario seguirne l'altro. Gli esempi seguenti sembrano inventati, per una buona ragione, ma in realtà li ho visti spuntare nel codice di produzione (e anche peggio):
Segue OCP ma non LSP
Diciamo che abbiamo il codice dato:
public interface IPerson {}
public class Boss implements IPerson {
public void doBossStuff() { ... }
}
public class Peon implements IPerson {
public void doPeonStuff() { ... }
}
public class Context {
public Collection<IPerson> getPersons() { ... }
}
Questo pezzo di codice segue il principio aperto-chiuso. Se chiamiamo il GetPersons
metodo del contesto , avremo un sacco di persone tutte con le proprie implementazioni. Ciò significa che IPerson è chiuso per modifica, ma aperto per estensione. Comunque le cose prendono una svolta oscura quando dobbiamo usarlo:
// in some routine that needs to do stuff with
// a collection of IPerson:
Collection<IPerson> persons = context.getPersons();
for (IPerson person : persons) {
// now we have to check the type... :-P
if (person instanceof Boss) {
((Boss) person).doBossStuff();
}
else if (person instanceof Peon) {
((Peon) person).doPeonStuff();
}
}
Devi fare il controllo del tipo e la conversione del tipo! Ricordi come ho detto sopra come il controllo del tipo è una cosa negativa ? Oh no! Ma non temere, come menzionato sopra o eseguire un refactoring pull-up o implementare uno schema Visitatore. In questo caso possiamo semplicemente fare un refactoring pull up dopo aver aggiunto un metodo generale:
public class Boss implements IPerson {
// we're adding this general method
public void doStuff() {
// that does the call instead
this.doBossStuff();
}
public void doBossStuff() { ... }
}
public interface IPerson {
// pulled up method from Boss
public void doStuff();
}
// do the same for Peon
Il vantaggio ora è che non è più necessario conoscere il tipo esatto, seguendo LSP:
// in some routine that needs to do stuff with
// a collection of IPerson:
Collection<IPerson> persons = context.getPersons();
for (IPerson person : persons) {
// yay, no type checking!
person.doStuff();
}
Segue LSP ma non OCP
Vediamo un codice che segue LSP ma non OCP, è un po 'inventato, ma sopporta con me su questo è un errore molto sottile:
public class LiskovBase {
public void doStuff() {
System.out.println("My name is Liskov");
}
}
public class LiskovSub extends LiskovBase {
public void doStuff() {
System.out.println("I'm a sub Liskov!");
}
}
public class Context {
private LiskovBase base;
// the good stuff
public void doLiskovyStuff() {
base.doStuff();
}
public void setBase(LiskovBase base) { this.base = base }
}
Il codice fa LSP perché il contesto può usare LiskovBase senza conoscere il tipo effettivo. Penseresti che questo codice segua anche OCP ma guardi da vicino, la classe è davvero chiusa ? E se il doStuff
metodo avesse fatto qualcosa di più della semplice stampa di una linea?
La risposta se segue OCP è semplicemente: NO , non è perché in questa progettazione di oggetti siamo tenuti a sostituire completamente il codice con qualcos'altro. Questo apre la lattina tagliata e incolla di worm poiché devi copiare il codice dalla classe base per far funzionare le cose. Il doStuff
metodo è sicuramente aperto per l'estensione, ma non è stato completamente chiuso per la modifica.
Siamo in grado di applicare il metodo di motivo template su questo. Il modello del modello di modello è così comune nei framework che potresti averlo usato senza saperlo (ad esempio componenti java swing, moduli e componenti c #, ecc.). Ecco un modo per chiudere il doStuff
metodo di modifica e assicurarsi che rimanga chiuso contrassegnandolo con la final
parola chiave java . Quella parola chiave impedisce a chiunque di sottoclassare ulteriormente la classe (in C # puoi usare sealed
per fare la stessa cosa).
public class LiskovBase {
// this is now a template method
// the code that was duplicated
public final void doStuff() {
System.out.println(getStuffString());
}
// extension point, the code that "varies"
// in LiskovBase and it's subclasses
// called by the template method above
// we expect it to be virtual and overridden
public string getStuffString() {
return "My name is Liskov";
}
}
public class LiskovSub extends LiskovBase {
// the extension overridden
// the actual code that varied
public string getStuffString() {
return "I'm sub Liskov!";
}
}
Questo esempio segue OCP e sembra sciocco, ma lo è, ma immagina che sia ingrandito con più codice da gestire. Continuo a vedere il codice distribuito in produzione in cui le sottoclassi hanno la precedenza su tutto e il codice sovrascritto viene principalmente tagliato e incollato tra le implementazioni. Funziona, ma come per tutta la duplicazione del codice è anche predisposto per incubi di manutenzione.
Conclusione
Spero che tutto ciò chiarisca alcune domande riguardanti OCP e LSP e le differenze / somiglianze tra loro. È facile respingerli come gli stessi, ma gli esempi sopra dovrebbero mostrare che non lo sono.
Nota che, raccogliendo dal codice di esempio sopra:
OCP consiste nel bloccare il codice di lavoro ma mantenerlo comunque aperto in qualche modo con alcuni punti di estensione.
Questo per evitare la duplicazione del codice incapsulando il codice che cambia come nell'esempio del modello Metodo modello. Permette anche di fallire rapidamente poiché i cambiamenti di rottura sono dolorosi (cioè cambiare un posto, romperlo ovunque). Per motivi di manutenzione, il concetto di incapsulare il cambiamento è una buona cosa, perché i cambiamenti avvengono sempre .
LSP consiste nel consentire all'utente di gestire diversi oggetti che implementano un supertipo senza verificare quale sia il tipo effettivo. Questo è intrinsecamente il polimorfismo .
Questo principio fornisce un'alternativa al controllo del tipo e alla conversione del tipo, che può sfuggire di mano all'aumentare del numero di tipi e può essere ottenuto attraverso il refactoring pull-up o l'applicazione di schemi come il visitatore.