Паттернология. Система команд

от
Совершенный код    java, паттерны проектирования, ооп

Святая троица «Инкапсуляция — Наследование — Полиморфизм» — это вершина айсберга под названием ООП. Это всего лишь инструменты для организации взаимодействия между объектами. В этом и есть смысл ООП — не просто создать кучу объектов, а сделать так, чтобы они эффективно взаимодействовали друг с другом. И в данном случае эффективность — это не скорость исполнения программы, а
возможность вносить правки в код максимально быстро, при этом не переписывая тонны кода.
Как я уже отмечал, с «движком для конфигов на LiketEngine» что-то не так. У него есть проблема — хоть он и написан на объектно-ориентированном языке (на самом деле Java уже давно не ОО, а мультипарадигма), он не ОО. В нём нет той самой расширяемости — если пользователь захочет добавить новую команду, ему придётся изменить реализацию класса CommandShell. А это нарушает несколько принципов SOLID:
  - Single responsibility principle (принцип единственной обязанности) — CommandShell берёт на себя слишком много обязанностей: тут и парсинг, и идентификация команд, и их исполнение. Добавление новой команды, изменение процедуры парсинга потребуют внесения правок в код класса...
  - Open/closed principle (принцип открытости/закрытости) — ... что может привести к изменению интерфейса, а следовательно и логике работы с экземплярами класса CommandShell. Помимо изменения самого класса, нам потребуется изменить и клиентский код.
Я не хочу досконально разбирать ни этот «движок», ни сам LiketEngine, поэтому покажу архитектурное решение, которое бы позволило избежать вышеупомянутых проблем, на синтетическом примере. Возьмём некий «текстовый процессор», а именно его систему команд.

Система командЗдесь нам придумывать что-то новое и оригинальное не нужно — достаточно полистать книжку по паттернам проектирования и найти Command (он же Action, он же Transaction).
Основная цель паттерна — инкапсулировать логику команды в отдельном классе. Для чего это нужно? Во-первых, мы минимизируем количество кода в нашем «исполнителе», а во-вторых мы сможем реализовать историю команд (одна из причин, по которой я не стал брать LiketEngine в качестве примера), но об этом чуть позже. И перейдём уже к коду.
Основное назначение текстового процессора — это (не удивляйтесь) редактирование текста. Поэтому определим класс Text.
  1. public class Text {
  2.   private String _text;
  3.   public Text(String initialText) {
  4.     _text = initialText;
  5.   }
  6.   public void setText(String text) {
  7.     _text = text;
  8.   }
  9.   public String getText() {
  10.     return _text;
  11.   }
  12. }

Интерфейс команды:
  1. /**
  2.  * Исполняемая команда.
  3.  */
  4. public interface Executable {
  5.   /**
  6.    * Исполняет команду.
  7.    */
  8.   void execute();
  9. }

Реализуем несколько команд для нашего игрушечного текстового процессора: добавление текста и замена (RegEx, конечно же).
  1. /**
  2.  * Команда, добавляющая текст в конец исходной строки.
  3.  */
  4. public class Append implements Executable {
  5.   private Text _text;
  6.   private String _appended;
  7.  
  8.   /**
  9.    * Создаёт команду Append.
  10.    * @param text      редактируемый текст
  11.    * @param appended  добавляемая строка
  12.    */
  13.   public Append(Text text, String appended) {
  14.     _text = text;
  15.     _appended = appended;
  16.   }
  17.  
  18.   @Override public void execute() {
  19.     _text.setText(_text.getText() + _appended);
  20.   }
  21. }
  22.  
  23. /**
  24.  * Команда, производящая замену по регулярному выражению.
  25.  */
  26. public class Replace implements Executable {
  27.   private Text _text;
  28.   private String _pattern;
  29.   private String _replace;
  30.  
  31.   /**
  32.    * Создаёт команду Replace.
  33.    * @param text      редактируемый текст
  34.    * @param pattern   регулярное выражение
  35.    * @param replace   замена
  36.    */
  37.   public Replace(Text text, String pattern, String replace) {
  38.     _text = text;
  39.     _pattern = pattern;
  40.     _replace = replace;
  41.   }
  42.  
  43.   @Override public void execute() {
  44.     Pattern p = Pattern.compile(pattern);
  45.     String result = p.matcher(_text.getText())
  46.       .replaceAll(_replace);
  47.     _text.setText(result);
  48.   }
  49. }

Всё. Мы только что реализовали простейшую систему команд. Вот пример того, как она работает:
  1. private static void sout(Text text) {
  2.   System.out.println(text.getText());
  3. }
  4. Text t = new Text("");
  5. new Append(t, "Hello World").execute();
  6. sout(t);
  7. new Replace(t, "World", "Everypony").execute();
  8. sout(t);
  1. > Hello World
  2. > Hello Everypony

История командЕсли в редакторе нельзя отменить команду — это плохой редактор. Исправим это.
Для начала определим интерфейс для команд, которые можно отменить:
  1. /**
  2.  * Отменяемая команда.
  3.  */
  4. public interface Undoable {
  5.   /**
  6.    * Отменяет команду.
  7.    */
  8.   void undo();
  9.   /**
  10.    * Повторяет команду.
  11.    */
  12.   void redo();
  13. }

И слегка перепишем наши команды:
  1. public class Append implements Executable, Undoable {
  2.   ...
  3.   private String _memento;
  4.  
  5.   public Append(...) {
  6.     ...
  7.     _memento = text.getText();
  8.   }
  9.   ...
  10.   @Override public void undo() {
  11.     _text.setText(_memento);
  12.   }
  13.  
  14.   @Override public void redo() {
  15.     execute();
  16.   }
  17. }
  18.  
  19. public class Replace implements Executable, Undoable {
  20.   ...
  21.   private String _memento;
  22.  
  23.   public Replace(...) {
  24.     ...
  25.     _memento = text.getText();
  26.   }
  27.   ...
  28.   @Override public void undo() {
  29.     _text.setText(memento);
  30.   }
  31.   @Override public void redo() {
  32.     execute();
  33.   }
  34. }

И класс который будет непосредственно управлять историей команд:
  1. public class CommandManager {
  2.   private Deque<Undoable> _undoStack;
  3.   private Deque<Undoable> _redoStack;
  4.  
  5.   public CommandManager() {
  6.     _undoStack = new ArrayDeque<>();
  7.     _redoStack = new ArrayDeque<>();
  8.   }
  9.  
  10.   /**
  11.    * Исполняет переданную команду и записывает её в историю команд, если она отменяемая.
  12.    */
  13.   public void execute(Executable cmd) {
  14.     cmd.execute();
  15.     if (cmd instanceof Undoable) {
  16.       _undoStack.push((Undoable)cmd);
  17.       _redoStack.clear();
  18.     }
  19.   }
  20.  
  21.   /**
  22.    * Отменяет последнюю команду.
  23.    */
  24.   public void undo() {
  25.     Undoable cmd = _undoStack.pop();
  26.     cmd.undo();
  27.     _redoStack.push(cmd);
  28.   }
  29.  
  30.   /**
  31.    * Повторяет отменённую команду.
  32.    */
  33.   public void redo() {
  34.     Undoable cmd = _redoStack.pop();
  35.     cmd.redo();
  36.     _undoStack.push(cmd);
  37.   }
  38. }

Использование:
  1. CommandManager cmd = new CommandManager();
  2. Text t = new Text("");
  3. cmd.execute(new Append(t, "Hello World"));
  4. sout(t);
  5. cmd.execute(new Replace(t, "World", "Everypony"));
  6. sout(t);
  7. cmd.undo();
  8. sout(t);
  9. cmd.redo();
  10. sout(t);
  1. > Hello World
  2. > Hello Everypony
  3. > Hello World
  4. > Hello Everypony

ВишенкаИ вишенка на торте — лямбды. К интерфейсу Executable можно добавить аннотацию @FunctionalInterface и вместо объектов использовать анонимные функции (только в Java >8, конечно же).
  1. cmd.execute(() -> t.setText("Lambdas are awesome!"));
  1. > Lambdas are awesome!

Code smellsИ на этом можно было бы закончить, если бы не одно «но» — оператор instanceof. Для ООП такой оператор сродни goto для процедурных языков. Не то, чтобы его нельзя было использовать, но пихать его всюду — идея не лучшая. По крайней мере, не в этом случае.
Исправить данную оплошность нам поможет так называемая «двойная диспетчеризация» — просто перенесём обязанности по добавлению в стек на сами команды.
Для это подправим интерфейс Executable...
  1. public interface Executable {
  2.   void execute(CommandManager cmdManager);
  3. }
... CommandManager...
  1. public class CommandManager {
  2.   ...
  3.   public void execute(Executable cmd) {
  4.     cmd.execute(this);
  5.   }
  6.  
  7.   public void addUndoable(Undoable cmd) {
  8.     _undoStack.push(cmd);
  9.     _redoStack.clear();
  10.   }
  11.   ...
  12. }

... и команды.
  1. public class Append implements Executable, Undoable {
  2.   ...
  3.   @Override public void execute(CommandManager cmdManager) {
  4.     doImpl();
  5.     cmdManager.addUndoable(this);
  6.   }
  7.  
  8.   private void doImpl() {
  9.     // Логика команды
  10.   }
  11.   ...
  12.  
  13.   @Override public void redo() {
  14.     doImpl();
  15.   }
  16. }

Заключение:пони:
  • +8
  • views 4179