Шаблон стратегії пояснюється використанням Java

У цьому дописі я розповім про одну з популярних моделей дизайну - модель Стратегії. Якщо ви ще не знаєте, шаблони проектування є сукупністю об’єктно-орієнтованих принципів програмування, створених відомими іменами в Індустрії програмного забезпечення, які часто називають Бандою Чотирьох (GoF). Ці шаблони проектування зробили величезний вплив на програмну екосистему і використовуються на сьогодні для вирішення загальних проблем, з якими стикаються об’єктно-орієнтоване програмування.

Давайте формально визначимо шаблон стратегії:

Шаблон стратегії визначає сімейство алгоритмів, інкапсулює кожен з них і робить їх взаємозамінними. Стратегія дозволяє алгоритму змінюватися незалежно від клієнтів, які його використовують

Гаразд, це не в дорозі, давайте зануримось у якийсь код, щоб зрозуміти, що ДІЙСНО означають ці слова. Ми візьмемо приклад із потенційною помилкою, а потім застосуємо схему стратегії, щоб побачити, як вона долає проблему.

Я покажу вам, як створити програму симулятора собак-допінгів, щоб вивчити шаблон стратегії. Ось як виглядатимуть наші класи: суперклас «собака» із загальною поведінкою, а потім конкретні класи собак, створені шляхом підкласу класу собак.

Ось як виглядає код

public abstract class Dog { public abstract void display(); //different dogs have different looks! public void eat(){} public void bark(){} // Other dog-like methods ... }

Метод display () робиться абстрактним, оскільки різні собаки мають різний вигляд. Усі інші підкласи успадкують поведінку їжі та кори або замінять її власною реалізацією. Все йде нормально!

А що, якби ви хотіли додати якусь нову поведінку? Скажімо, вам потрібна крута собака-робот, яка вміє робити всілякі трюки. Не проблема, нам просто потрібно додати метод performTricks () до нашого суперкласу Dog, і ми готові піти.

Але почекайте хвилинку ... Собака-робот не повинна вміти правильно харчуватися? Неживі предмети, звичайно, їсти не можуть. Гаразд, як нам тоді вирішити цю проблему? Ну, ми можемо замінити метод eat (), щоб нічого не робити, і він працює чудово!

public class RobotDog extends Dog { @override public void eat(){} // Do nothing }

Чудово зроблено! Тепер собаки-роботи не можуть їсти, вони можуть лише гавкати або виконувати трюки. А як щодо гумових собак? Вони не можуть ні їсти, ні виконувати трюки. А дерев’яні собаки не можуть ні їсти, ні гавкати, ні виконувати трюки. Ми не завжди можемо замінити методи, щоб нічого не робити, це не чисто і просто відчуває себе хакі. Уявіть, що ви робите це на проекті, специфікація проекту якого постійно змінюється кожні кілька місяців. Наш - це просто наївний приклад, але ви розумієте. Отже, нам потрібно знайти більш чистий спосіб вирішити цю проблему.

Чи може інтерфейс вирішити нашу проблему?

Як щодо інтерфейсів? Давайте подивимось, чи зможуть вони вирішити нашу проблему. Добре, ми створюємо CanEat та інтерфейс CanBark:

interface CanEat { public void eat(); } interface CanBark { public void bark(); }

Тепер ми видалили методи bark () та eat () із суперкласу Dog та додали їх до відповідних інтерфейсів. Таким чином, лише собаки, які можуть гавкати, реалізують інтерфейс CanBark, а собаки, які можуть їсти, реалізують інтерфейс CanEat. Тепер, більше не турбуючись про те, що собаки успадковують поведінку, якої вони не повинні, наша проблема вирішена ... чи це так?

Що відбувається, коли нам доводиться змінювати харчову поведінку собак? Скажімо, відтепер кожна собака повинна включати певну кількість білка під час їжі. Тепер вам доведеться змінити метод eat () усіх підкласів собаки. Що, якщо таких занять 50, о жах!

Отже, інтерфейси лише частково вирішують нашу проблему, пов’язану з тим, що Собаки роблять лише те, що вони здатні, але вони створюють іншу проблему взагалі. Інтерфейси не мають коду реалізації, тому існує багаторазове повторне використання коду та велика кількість повторюваних кодів. Як ми вирішуємо це, про що ви запитуєте? На допомогу приходить шаблон стратегії!

Шаблон стратегії

Тож ми будемо робити це поетапно. Перш ніж продовжити, дозвольте познайомити вас із принципом дизайну:

Визначте частини вашої програми, які відрізняються, і відокремте їх від того, що залишається незмінним.

Насправді це дуже просто - принцип передбачає відокремлювати та “інкапсулювати” все, що часто змінюється, щоб весь код, що змінюється, жив в одному місці. Таким чином, код, який змінюється, не матиме жодного впливу на решту програми, і наш додаток стане більш гнучким та надійним.

У нашому випадку поведінка «гавкання» та «їсти» може бути вилучена з класу собак і може бути інкапсульована в іншому місці. Ми знаємо, що ця поведінка відрізняється у різних собак, і вони повинні отримати свій окремий клас.

Ми збираємося створити два набори класів, крім класу собак, один для визначення харчової поведінки та один для поведінки гавкання. Ми використаємо інтерфейси для представлення поведінки, таких як 'EatBehavior' та 'BarkBehavior', а конкретний клас поведінки реалізує ці інтерфейси. Отже, клас Dog більше не реалізує інтерфейс. Ми створюємо окремі класи, єдиною роботою яких є представлення конкретної поведінки!

Ось так виглядає інтерфейс EatBehavior

interface EatBehavior { public void eat(); }

І BarkBehaviour

interface BarkBehavior { public void bark(); }

Усі класи, що представляють ці типи поведінки, реалізовуватимуть відповідний інтерфейс.

Конкретні класи для BarkBehaviour

public class PlayfulBark implements BarkBehavior { @override public void bark(){ System.out.println("Bark! Bark!"); } } public class Growl implements BarkBehavior { @override public void bark(){ System.out.println("This is a growl"); } public class MuteBark implements BarkBehavior { @override public void bark(){ System.out.println("This is a mute bark"); }

Конкретні класи для EatBehavior

public class NormalDiet implements EatBehavior { @override public void eat(){ System.out.println("This is a normal diet"); } } public class ProteinDiet implements EatBehavior { @override public void eat(){ System.out.println("This is a protein diet"); } }

Зараз, коли ми робимо конкретні реалізації, підкласуючи суперклас «Собака», природно, ми хочемо мати можливість динамічно призначати поведінку екземплярам собак. Зрештою, саме гнучкість попереднього коду спричинила проблему. Ми можемо визначити методи встановлення в підкласі Dog, які дозволять нам встановлювати різні способи поведінки під час виконання.

Це підводить нас до іншого принципу дизайну:

Програма на інтерфейс, а не реалізація.

Це означає, що замість використання конкретних класів ми використовуємо змінні, які є супертипами цих класів. Іншими словами, ми використовуємо змінні типу EatBehavior та BarkBehavior і призначаємо цим змінним об'єкти класів, що реалізують цю поведінку. Таким чином, класам Dog не потрібно мати ніякої інформації про фактичні типи об’єктів цих змінних!

Щоб зробити концепцію зрозумілою, ось приклад, який відрізняє два шляхи - розглянемо абстрактний клас Animal, який має дві конкретні реалізації - Dog і Cat.

Програмування для реалізації буде таким:

Dog d = new Dog(); d.bark();

Ось як виглядає програмування інтерфейсу:

Animal animal = new Dog(); animal.animalSound();

Тут ми знаємо, що тварина містить екземпляр "собаки", але ми можемо використовувати це посилання поліморфно скрізь в іншому місці нашого коду. Все, що нас турбує, це те, що екземпляр тварини може реагувати на метод animalSound (), і відповідний метод, залежно від призначеного об’єкта, викликається.

Це було дуже багато для прийняття. Без подальших пояснень давайте подивимось, як зараз виглядає наш суперклас „Собака”:

public abstract class Dog { EatBehavior eatBehavior; BarkBehaviour barkBehavior; public Dog(){} public void doBark() { barkBehavior.bark(); } public void doEat() { eatBehavior.eat(); } }

Зверніть пильну увагу на методи цього класу. Клас Собаки тепер "делегує" завдання їсти та гавкати, замість того, щоб реалізовувати сам або успадковувати його (підклас). У методі doBark () ми просто викликаємо метод bark () на об'єкті, на який посилається barkBehavior. Зараз нас не хвилює фактичний тип об’єкта, нам цікавить лише те, чи вміє він гавкати!

Тепер момент істини, давайте створимо конкретну Собаку!

public class Labrador extends Dog { public Labrador(){ barkBehavior = new PlayfulBark(); eatBehavior = new NormalDiet(); } public void display(){ System.out.println("I'm a playful Labrador"); } ... }

What’s happening in the constructor of the Labrador class? we are assigning the concrete instances to the supertype (remember the interface types are inherited from the Dog superclass). Now, when we call doEat() on the Labrador instance, the responsibility is handed over to the ProteinDiet class and it executes the eat() method.

The Strategy Pattern in Action

Alright, let’s see this in action. The time has come to run our dope Dog simulator program!

public class DogSimulatorApp { public static void main(String[] args) { Dog lab = new Labrador(); lab.doEat(); // Prints "This is a normal diet" lab.doBark(); // "Bark! Bark!" } }

How can we make this program better? By adding flexibility! Let’s add setter methods on the Dog class to be able to swap behaviors at runtime. Let’s add two more methods to the Dog superclass:

public void setEatBehavior(EatBehavior eb){ eatBehavior = eb; } public void setBarkBehavior(BarkBehavior bb){ barkBehavior = bb; }

Now we can modify our program and choose whatever behavior we like at runtime!

public class DogSimulatorApp { public static void main(String[] args){ Dog lab = new Labrador(); lab.doEat(); // This is a normal diet lab.setEatBehavior(new ProteinDiet()); lab.doEat(); // This is a protein diet lab.doBark(); // Bark! Bark! } }

Let’s look at the big picture:

We have the Dog superclass and the ‘Labrador’ class which is a subclass of Dog. Then we have the family of algorithms (Behaviors) “encapsulated” with their respective behavior types.

Take a look at the formal definition that I gave at the beginning: the algorithms are nothing but the behavior interfaces. Now they can be used not only in this program but other programs can also make use of it. Notice the relationships between the classes in the diagram. The IS-A and HAS-A relationships can be inferred from the diagram.

That’s it! I hope you have gotten a big picture overview of the Strategy pattern. The Strategy pattern is extremely useful when you have certain behaviors in your app that change constantly.

This brings us to the end of the Java implementation. Thank you so much for sticking with me so far! If you are interested to learn about the Kotlin version, stay tuned for the next post. I talk about interesting language features and how we can reduce all of the above code in a single Kotlin file :)

P.S

I have read the Head First Design Patterns book and most of this post is inspired by its content. I would highly recommend this book to anyone who is looking for a gentle introduction to Design Patterns.