Привет! В предыдущих лекциях мы уже встречались с таким понятием как «паттерн проектирования». Если ты забыл, напомним: этим термином обозначают некое стандартное решение распространенной в программировании задачи. Обзор самых ходовых решений собран в статье про паттерны проектирования в Java. Красный спортивный автомобиль с гоночным номером 17 и рекламой Camel на борту стоит рядом с микроавтобусомНа JavaRush мы часто говорим, что ответ практически на любой вопрос можно загуглить. Поэтому задачу, похожую на твою, наверняка уже кто-то успешно решил. Так вот, паттерны это проверенные временем и практикой решения наиболее распространенных задач или методы разрешения проблемных ситуаций. Это те самые «велосипеды», которые ни в коем случае не нужно изобретать самому, но нужно знать, как и когда их применить :) Другая задача паттернов это приведение архитектуры к единому стандарту. Читать чужой код это задача не из легких! Все пишут его по-разному, ведь одну и ту же задачу можно решить многими способами. Но использование паттернов позволяет разным программистам понять логику работы программы, не вникая в каждую строку кода (даже если они видят его впервые!) Сегодня мы рассмотрим один из наиболее распространенных паттернов под названием «Стратегия». Если коротко, «Стратегия» это поведенческий паттерн: семейство взаимозаменяемых алгоритмов выносится из класса в отдельный интерфейс, объект хранит ссылку на нужную реализацию и при желании подменяет ее прямо во время работы программы. Поведение перестает быть намертво зашитым в иерархию наследования.

Кратко

  • «Стратегия» это поведенческий паттерн: семейство взаимозаменяемых алгоритмов выносится из класса в отдельный интерфейс.
  • Проблема, которую он решает: если задавать поведение наследованием, метод достается и тем потомкам, которым он не нужен, и его приходится переопределять заглушкой.
  • Обычный интерфейс тоже не спасает: реализацию придется писать заново в каждом классе, а их могут быть десятки.
  • Решение: объявить интерфейс стратегии, сделать несколько его реализаций и хранить в классе поле типа интерфейса, а не конкретного класса.
  • Конкретную стратегию удобно задавать в конструкторе, а сеттер позволяет менять ее уже после создания объекта.
  • Выгода: чтобы изменить алгоритм заправки, достаточно поправить один класс-стратегию, классы машин при этом не трогаются вообще.
Черная шахматная ладья на желто-коричневой доске

Задача: добавить машинам заправку

Представим, что мы пишем программу, активно работающую с объектом Автомобиль. В данном случае даже не особо важно, что именно делает наша программа. Для этого мы создали систему наследования с одним родительским классом Auto и тремя дочерними классами: Sedan, Truck и F1Car.

public class Auto {

   public void gas() {
       System.out.println("Едем вперед");
   }

   public void stop() {

       System.out.println("Тормозим!");
   }
}

public class Sedan extends Auto {
}

public class Truck extends Auto {
}

public class F1Car extends Auto {
}
Все три дочерних класса наследуют от родительского два стандартных метода: gas() и stop() Программа у нас совсем простая: машины умеют только ехать вперед и тормозить. Продолжая нашу работу, мы решили добавить машинам новый метод fill() (заправить топливо). Добавим его в родительский класс Auto:

public class Auto {

   public void gas() {
       System.out.println("Едем вперед");
   }

   public void stop() {

       System.out.println("Тормозим!");
   }
  
   public void fill() {
       System.out.println("Заправить бензин!");
   }
}
Казалось бы, разве могут в такой простой ситуации возникнуть проблемы? Ну, на самом деле, они уже возникли… Красный радиоуправляемый багги едет по дорожке среди сухой травы: тот самый детский багги из примера

public class ChildrenBuggies extends Auto {

   public void fill() {
      
       //хм... Это детский багги, его не надо заправлять :/
   }
}
В нашей программе появился автомобиль, который не вписывается в общую концепцию, это детский багги. Он может быть с педалями или радиоуправляемым, но одно ясно точно: бензин в него заливать некуда. Наша схема наследования привела к тому, что мы раздали общие методы даже тем классам, в которых они не нужны. Это классический довод в пользу композиции вместо наследования, сами отношения между классами разобраны в статье про наследование, композицию и агрегирование. Что нам делать в такой ситуации? Ну, например, можно переопределить метод fill() в классе ChildrenBuggies, чтобы при попытке заправить багги ничего не происходило:

public class ChildrenBuggies extends Auto {

   @Override
   public void fill() {
       System.out.println("Игрушечную машину нельзя заправить!");
   }
}
Но это решение сложно назвать удачным как минимум из-за дублирования кода. К примеру, большая часть классов будет использовать метод из родительского класса, но другая часть классов будет вынуждена его переопределить. Если у нас будет 15 классов, и в 5-6 мы будем вынуждены переопределять поведение, дублирование кода станет довольно обширным.

Спасут ли интерфейсы

Может, нам смогут помочь интерфейсы? Например, вот такой:

public interface Fillable {
  
   public void fill();
}
Мы создадим интерфейс Fillable с одним методом fill(). Соответственно, те машины, которые необходимо заправлять, будут имплементировать этот интерфейс, а другие машины (к примеру, наш багги) не будут. Но и этот вариант нам не подойдет. Наша иерархия классов может в будущем разрастись до очень большого числа (представь, сколько разных видов автомобилей есть на свете). Мы отказались от предыдущего варианта с наследованием, потому что не хотели много раз переопределять метод fill(). Здесь же нам придется реализовывать его в каждом классе! А что если их у нас будет 50? И если в нашу программу будут вноситься частые изменения (а в реальных программах почти всегда так и будет!), нам придется носиться с высунутым языком между всеми 50 классами и менять поведение каждого из них вручную. Так как же нам в итоге поступить?

Решение: выносим поведение в отдельный интерфейс

Чтобы решить нашу проблему, выберем иной путь. А именно: отделим поведение нашего класса от самого класса. Что это значит? Как ты знаешь, любой объект имеет состояние (набор данных) и поведение (набор методов). Поведение нашего класса машин состоит из трех методов: gas(), stop() и fill(). С первыми двумя методами все в порядке. А вот третий метод мы вынесем за пределы класса Auto. Это и будет отделением поведения от класса (точнее, мы отделяем только часть поведения, два первых метода остаются на месте). Куда же мы должны перенести наш метод fill()? С ходу ничего не приходит в голову :/ Он, вроде как, был вполне на своем месте. Мы перенесем его в отдельный интерфейс FillStrategy!

public interface FillStrategy {

   public void fill();
}
Зачем нам нужен этот интерфейс? Все просто. Теперь мы сможем создать несколько классов, которые будут этот интерфейс реализовывать:

public class HybridFillStrategy implements FillStrategy {
  
   @Override
   public void fill() {
       System.out.println("Заправляем бензином или электричеством на выбор!");
   }
}

public class F1PitstopStrategy implements FillStrategy {
  
   @Override
   public void fill() {
       System.out.println("Заправляем бензин только после всех остальных процедур пит-стопа!");
   }
}

public class StandartFillStrategy implements FillStrategy {
   @Override
   public void fill() {
       System.out.println("Просто заправляем бензин!");
   }
}
Мы создали три стратегии поведения: для обычных машин, для гибридов и для болидов Формулы-1. Каждая стратегия реализует отдельный алгоритм заправки. В нашем случае это просто вывод в консоль, но внутри метода может быть и какая-то сложная логика. Что же нам делать с этим дальше?

public class Auto {

   FillStrategy fillStrategy;

   public void fill() {
       fillStrategy.fill();
   }

   public void gas() {
       System.out.println("Едем вперед");
   }

   public void stop() {
       System.out.println("Тормозим!");
   }
  
}
Мы используем наш интерфейс FillStrategy в качестве поля в родительском классе Auto. Обрати внимание: мы не указываем конкретную реализацию, а используем именно интерфейс. А конкретные реализации интерфейса FillStrategy понадобятся нам в дочерних классах-автомобилях:

public class F1Car extends Auto {

   public F1Car() {
       this.fillStrategy = new F1PitstopStrategy();
   }
}

public class HybridAuto extends Auto {

   public HybridAuto() {
       this.fillStrategy = new HybridFillStrategy();
   }
}

public class Sedan extends Auto {

   public Sedan() {
       this.fillStrategy = new StandartFillStrategy();
   }
}

Посмотрим, что у нас получилось:

public class Main {

   public static void main(String[] args) {

       Auto sedan = new Sedan();
       Auto hybrid = new HybridAuto();
       Auto f1car = new F1Car();

       sedan.fill();
       hybrid.fill();
       f1car.fill();
   }
}
Вывод в консоль:

Просто заправляем бензин!
Заправляем бензином или электричеством на выбор!
Заправляем бензин только после всех остальных процедур пит-стопа!
Отлично, процесс заправки работает как надо!

Стратегия через конструктор

Кстати, ничто не мешает нам использовать стратегию в качестве параметра в конструкторе! Например, вот так:

public class Auto {

   private FillStrategy fillStrategy;

   public Auto(FillStrategy fillStrategy) {
       this.fillStrategy = fillStrategy;
   }

   public void fill() {
       this.fillStrategy.fill();
   }

   public void gas() {
       System.out.println("Едем вперед");
   }

   public void stop() {
       System.out.println("Тормозим!");
   }
}

public class Sedan extends Auto {

   public Sedan() {
       super(new StandartFillStrategy());
   }
}



public class HybridAuto extends Auto {

   public HybridAuto() {
       super(new HybridFillStrategy());
   }
}

public class F1Car extends Auto {

   public F1Car() {
       super(new F1PitstopStrategy());
   }
}
Запустим наш метод main() (он остался без изменений), и получим тот же результат! Вывод в консоль:

Просто заправляем бензин!
Заправляем бензином или электричеством на выбор!
Заправляем бензин только после всех остальных процедур пит-стопа!

Определение паттерна «Стратегия»

Паттерн «Стратегия» определяет семейство алгоритмов, инкапсулирует каждый из них и обеспечивает их взаимозаменяемость. Он позволяет модифицировать алгоритмы независимо от их использования на стороне клиента. (это определение взято из книги “Изучаем паттерны проектирования” и кажется мне крайне удачным). Обложка книги Head First. Паттерны проектирования Эрика Фримена и Элизабет Робсон, издательство ПитерМы выделили интересующее нас семейство алгоритмов (виды заправки машин) в отдельный интерфейс с несколькими реализациями. Мы отделили их от самой сущности автомобиля. Поэтому теперь, если нам понадобится внести какие-то изменения в тот или иной процесс заправки, это никак не затронет наши классы машин. Соберем три подхода, которые мы перебрали, в одну таблицу:
Подход Как выглядит Результат
Метод в родительском классе fill() объявлен в Auto, все наследники получают его автоматически Дублирование: классам вроде ChildrenBuggies приходится переопределять метод заглушкой
Обычный интерфейс Fillable Метод объявлен в интерфейсе, реализуют его только те классы, которым он нужен Дублирование: реализацию пишем заново в каждом из десятков классов
Интерфейс-стратегия FillStrategy Алгоритмы вынесены в отдельные классы, Auto хранит поле типа интерфейса Подходит: алгоритм правится в одном месте и подставляется любой машине

Взаимозаменяемость: меняем стратегию на ходу

Что касается взаимозаменяемости, то для ее достижения нам достаточно добавить один метод-сеттер в наш класс Auto:

public class Auto {

   FillStrategy fillStrategy;

   public void fill() {
       fillStrategy.fill();
   }

   public void gas() {
       System.out.println("Едем вперед");
   }

   public void stop() {
       System.out.println("Тормозим!");
   }

   public void setFillStrategy(FillStrategy fillStrategy) {
       this.fillStrategy = fillStrategy;
   }
}
Теперь мы можем менять стратегии на ходу:

public class Main {

   public static void main(String[] args) {

       ChildrenBuggies buggies = new ChildrenBuggies();
       buggies.setFillStrategy(new StandartFillStrategy());

       buggies.fill();
   }
}
Если вдруг детские машины-багги начнут заправлять бензином, наша программа будет готова к такому варианту развития событий :) Вот, собственно, и все! Ты выучил еще один паттерн проектирования, который, несомненно, тебе понадобится и еще не раз выручит в работе над реальными проектами :) До новых встреч!

Вопросы и ответы

Что такое паттерн «Стратегия»?

Это поведенческий паттерн проектирования. Он выносит семейство взаимозаменяемых алгоритмов из класса в отдельный интерфейс, а объект хранит ссылку на нужную реализацию. Классическое определение: паттерн определяет семейство алгоритмов, инкапсулирует каждый из них и обеспечивает их взаимозаменяемость, позволяя менять алгоритмы независимо от клиента, который ими пользуется.

Какую проблему решает «Стратегия»?

Жесткую привязку поведения к иерархии наследования. Если метод объявлен в родительском классе, его получают все наследники, даже те, кому он не нужен, и им приходится переопределять его заглушкой. Если вынести метод в обычный интерфейс, реализацию придется писать заново в каждом классе. «Стратегия» убирает и то, и другое: алгоритм живет в отдельном классе, а объекту достаточно хранить ссылку на него.

Чем «Стратегия» отличается от обычного наследования?

При наследовании поведение фиксируется на этапе компиляции: класс F1Car навсегда получает тот метод fill(), который написан в нем самом или в Auto. При «Стратегии» поведение это отдельный объект, лежащий в поле. Его можно передать в конструктор, а можно заменить сеттером уже после создания объекта. Это композиция вместо наследования.

Как передать объекту нужную стратегию?

Тремя способами. Задать прямо в конструкторе наследника: this.fillStrategy = new F1PitstopStrategy(). Передать параметром в конструктор родителя: super(new HybridFillStrategy()). Или выставить сеттером в любой момент работы программы: auto.setFillStrategy(new StandartFillStrategy()). Первые два способа фиксируют поведение при создании объекта, третий позволяет менять его на ходу.

Где «Стратегия» встречается в самой Java?

Самый известный пример это Comparator. Метод сортировки один, а правило сравнения передается ему отдельным объектом. Поменяли компаратор, поменялся порядок сортировки, при этом код самой сортировки остался нетронутым. Это в точности «Стратегия»: семейство взаимозаменяемых алгоритмов за одним интерфейсом.

Когда «Стратегия» не нужна?

Когда алгоритм один и меняться не собирается. Паттерн добавляет интерфейс и по отдельному классу на каждый вариант поведения, и если вариант всего один, такая конструкция только усложняет код. «Стратегия» окупается там, где алгоритмов несколько, они действительно взаимозаменяемы и набор будет пополняться.

Читайте также