Боже, є деякі дивні помилки щодо того, що OCP та LSP, а деякі пояснюються невідповідністю деяких термінологій та заплутаними прикладами. Обидва принципи - це лише "те саме", якщо ви реалізуєте їх однаково. Шаблони зазвичай так чи інакше дотримуються принципів за невеликими винятками.
Відмінності будуть пояснені далі, але спочатку давайте зануримось у самі принципи:
Принцип відкритого закриття (OCP)
За словами дядька Боба :
Ви повинні мати можливість розширити поведінку класів, не змінюючи її.
Зауважте, що слово розширення в цьому випадку не обов'язково означає, що вам слід підкласити фактичний клас, який потребує нової поведінки. Подивіться, як я спочатку згадував невідповідність термінології? Ключове слово extend
означає лише підкласифікацію в Java, але принципи старіші за Java.
Оригінал прийшов від Бертрана Мейєра в 1988 році:
Програмні об'єкти (класи, модулі, функції тощо) повинні бути відкритими для розширення, але закритими для модифікації.
Тут набагато зрозуміліше, що принцип застосовується до програмних об'єктів . Поганим прикладом може бути переосмислення програмного забезпечення, коли ви повністю змінюєте код, а не надаєте деяку точку розширення. Поведінка самого програмного забезпечення має бути розширюваним, і хорошим прикладом цього є реалізація стратегії (оскільки це найпростіший показ групи ІМХО-шаблонів 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();
}
У наведеному вище прикладі Context
буде заблокована для подальших модифікацій. Більшість програмістів, ймовірно, хочуть підкласирувати клас, щоб розширити його, але тут ми цього не робимо, оскільки він передбачає, що його поведінку можна змінити через все, що реалізує IBehavior
інтерфейс.
Тобто контекстний клас закритий для модифікації, але відкритий для розширення . Насправді слід ще один основний принцип, тому що ми ставимо поведінку з об'єктною композицією замість успадкування:
"Вибрана композиція об'єкта " над " успадкуванням класу ". " (Банда з чотирьох 1995: 20)
Я дозволю читачеві читати за цим принципом, оскільки він знаходиться поза рамками цього питання. Для продовження прикладу скажімо, що у нас є такі реалізації інтерфейсу 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!");
}
}
Використовуючи цю закономірність, ми можемо модифікувати поведінку контексту під час виконання за допомогою setBehavior
методу як точки розширення.
// 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!"
Отже, коли ви хочете розширити "закритий" контекстний клас, робіть це, підкласифікуючи це "відкрита" співпраця, що співпрацює. Це, очевидно, не те саме, що підкласифікувати сам контекст, але це OCP. Про це LSP також не згадує.
Розширення міксинами замість спадкування
Є й інші способи зробити OCP, крім підкласифікації. Один із способів - тримати заняття відкритими для розширення через використання міксинів . Це корисно, наприклад, на мовах, заснованих на прототипі, а не на основі класів. Ідея полягає у внесенні змін до динамічного об'єкта за допомогою більшої кількості методів чи атрибутів, тобто об'єктами, які поєднуються або «змішуються» з іншими об’єктами.
Ось javascript-приклад змішання, який надає простий HTML-шаблон для якорів:
// 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>
Ідея полягає в тому, щоб об'єкти динамічно розширювались, і перевага цього полягає в тому, що об’єкти можуть ділитися методами, навіть якщо вони знаходяться в абсолютно інших областях. У наведеному вище випадку ви можете легко створювати інші види HTML-якорів, розширивши вашу конкретну реалізацію на LinkMixin
.
З точки зору OCP, "mixins" - це розширення. У наведеному вище прикладі - YoutubeLink
це наше програмне забезпечення, яке закрите для модифікації, але відкрите для розширення через використання mixins. Ієрархія об’єктів вирівнюється, що унеможливлює перевірку типів. Однак це насправді не погано, і далі я поясню, що перевірка типів, як правило, є поганою ідеєю і розбиває ідею з поліморфізмом.
Зауважте, що за допомогою цього методу можна зробити багатократне успадкування, оскільки більшість extend
реалізацій можуть поєднувати кілька об'єктів:
_.extend(MyClass, Mixin1, Mixin2 /* [, ...] */);
Єдине, про що потрібно пам’ятати - це не зіткнути імена, тобто міксини мають визначати те саме ім’я деяких атрибутів чи методів, як вони будуть відмінені. На мій скромний досвід, це не проблема, і якщо це все-таки трапляється, це вказівка на хибний дизайн.
Принцип заміщення Ліскова (LSP)
Дядько Боб визначає це просто:
Отримані класи повинні бути замінними для їх базових класів.
Цей принцип є старим, насправді визначення дядька Боба не розмежовує принципи, завдяки чому LSP все ще тісно пов'язаний з OCP тим, що в наведеному вище прикладі Стратегії використовується той же супертип ( IBehavior
). Тож давайте подивимось на це оригінальне визначення Барбари Ліськов і подивимось, чи зможемо ми дізнатися ще щось про цей принцип, схожий на математичну теорему:
Тут потрібно щось подібне до властивості підстановки: Якщо для кожного об'єкта o1
типу S
є такий o2
тип об'єкта T
, що для всіх програм, P
визначених в термінах T
, поведінка P
не змінюється, коли o1
заміщене, o2
то S
є підтипом T
.
Давайте на деякий час плечуть це, зауважте, оскільки це взагалі не згадує заняття. У JavaScript ви можете наслідувати LSP, навіть не будучи явним класом. Якщо у вашій програмі є список щонайменше декількох об’єктів JavaScript, які:
- потрібно обчислювати так само,
- мають таку саму поведінку, і
- інакше в чомусь зовсім інші
... тоді об'єкти розглядаються як такі, що мають "тип", і це насправді не має значення для програми. Це по суті поліморфізм . У родовому сенсі; вам не потрібно знати фактичний підтип, якщо ви використовуєте його інтерфейс. OCP не говорить про це нічого чіткого. Це також фактично визначає помилку дизайну, яку роблять більшість початківців програмістів:
Кожного разу, коли ви відчуваєте бажання перевірити підтип об'єкта, ви, швидше за все, робите це НЕПРАВНО.
Добре, так що це не може бути неправильно весь час , але якщо у вас є бажання зробити деякі перевірки типів з instanceof
або перерахуваннями, ви могли б робити програму трохи більш заплутаною для себе , ніж це повинно бути. Але це не завжди так; швидкі та брудні хаки, щоб налагодити роботу, - це гарна поступка, яку потрібно зробити, якщо рішення досить мало, і якщо ви практикуєте нещадне рефакторинг , воно може покращитися, коли зміни вимагатимуть цього.
Існують способи навколо цієї "дизайнерської помилки", залежно від фактичної проблеми:
- Суперклас не викликає передумов, змушує абонента зробити це замість цього.
- У суперкласі відсутній загальний метод, який потребує абонента.
І те і інше - це звичайні помилки в дизайні коду. Ви можете зробити кілька різних рефакторингах, таких як метод підтягування або рефактор для такого шаблону як відвідувач .
Мені насправді дуже подобається шаблон Visitor, оскільки він може піклуватися про великі спагеті if-statement, і це простіше в застосуванні, ніж те, що ви думаєте про існуючий код. Скажімо, у нас такий контекст:
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"
Результати if-заяви можна перевести на власних відвідувачів, оскільки кожен залежить від певного рішення та коду, який потрібно виконати. Ми можемо витягти так:
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");
}
}
У цей момент, якщо програміст не знав про шаблон відвідувачів, він замість цього застосував клас контексту, щоб перевірити, чи є він певного типу. Оскільки в класах Visitor є булевий canDo
метод, реалізатор може використовувати цей виклик методу, щоб визначити, чи це правильний об'єкт для виконання завдання. Контекстний клас може використовувати всіх відвідувачів (і додавати нових) так:
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);
}
}
Обидва зразки дотримуються OCP та LSP, однак обидва вони чітко вказують на них. То як виглядає код, якщо він порушує один із принципів?
Порушення одного принципу, але слідування іншому
Є способи порушити один із принципів, але все ж таки слід дотримуватися іншого. Наведені нижче приклади здаються надуманими, з поважної причини, але я насправді бачив, як вони з'являються у виробничому коді (і навіть гірше):
Дотримується OCP, але не LSP
Скажімо, у нас є даний код:
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() { ... }
}
Цей фрагмент коду відповідає принципу відкритого закритого типу. Якщо ми зателефонуємо GetPersons
методу контексту , ми отримаємо купу людей, які мають власні реалізації. Це означає, що IPerson закритий для модифікації, але відкритий для розширення. Однак справи мають темний виток, коли нам доведеться його використовувати:
// 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();
}
}
Ви повинні зробити перевірку типу та перетворення типів! Пам'ятайте, як я згадував вище, як перевірка типу - це погано ? О ні! Але не бійтеся, як також було сказано вище, або виконайте деякий рефакторинг, або підтягуйте шаблон відвідувачів. У цьому випадку ми можемо просто зробити рефакторинг після підключення загального методу:
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
Перевага полягає в тому, що вам більше не потрібно знати точний тип, дотримуючись 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();
}
Дотримується LSP, але не OCP
Давайте подивимось на якийсь код, який слід за LSP, але не OCP, він надуманий, але потерпіть мене на цьому, це дуже тонка помилка:
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 }
}
Код робить LSP, оскільки контекст може використовувати LiskovBase, не знаючи фактичного типу. Ви можете подумати, що цей код також відповідає OCP, але придивіться, чи клас закритий ? Що робити, якщо doStuff
метод зробив більше, ніж просто роздрукувати рядок?
Відповідь, якщо випливає з OCP, проста: НІ , це не тому, що в такому дизайні об'єктів від нас вимагається повністю перекрити код чимось іншим. Це відкриває баночку з вирізаними та вставитими хробаками, оскільки вам доведеться скопіювати код із базового класу, щоб налагодити роботу. doStuff
Метод впевнений , відкритий для розширення, але він не був повністю закритий для модифікації.
На цьому ми можемо застосувати шаблон методу Шаблон . Шаблон методу шаблону настільки поширений у фреймворках, що ви, можливо, використовували його, не знаючи про це (наприклад, компоненти компонентів java swing, c # форми та компоненти тощо). Ось один із способів закрити doStuff
метод модифікації та переконатися, що він залишається закритим, позначивши його final
ключовим словом java . Це ключове слово не дозволяє комусь додатково підкласифікувати клас (у C # ви можете використовувати sealed
те саме).
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!";
}
}
Цей приклад слідує за OCP і здається нерозумним, яким він є, але уявіть, що це масштабується з більшим кодом для обробки. Я продовжую бачити код, розгорнутий у виробництві, де підкласи повністю перекривають усе, а переосмислений код здебільшого вирізається між n-вставленими. Це працює, але як і всі дублювання коду, це також налаштування для кошмарів технічного обслуговування.
Висновок
Я сподіваюся, що все це очищує деякі питання щодо OCP та LSP та відмінності / подібності між ними. Легко відхилити їх як однакові, але наведені вище приклади повинні показувати, що вони не є.
Зауважте, що, збираючи зверху зразок коду:
OCP полягає в тому, щоб заблокувати робочий код, але все одно тримайте його відкритим якось із деякими точками розширення.
Це дозволяє уникнути дублювання коду шляхом інкапсуляції коду, який змінюється, як у прикладі шаблону методу шаблону. Це також дозволяє швидко провалюватися, оскільки порушення змін болючі (тобто змінити одне місце, порушити його всюди в іншому місці). Задля підтримки концепція інкапсуляції змін - це добре, адже зміни завжди відбуваються.
LSP полягає в тому, щоб дозволити користувачеві обробляти різні об'єкти, що реалізують супертип, не перевіряючи, який вони фактичний тип. Це суттєво те , про що йдеться у поліморфізмі .
Цей принцип надає альтернативу для перевірки типу та перетворення типів, яка може вийти з ладу в міру зростання кількості типів, і це може бути досягнуто за допомогою рефакторингу підтягувань або застосування шаблонів, таких як Visitor.