Паттернология. Система команд
от aRiGaTo
Святая троица «Инкапсуляция — Наследование — Полиморфизм» — это вершина айсберга под названием ООП. Это всего лишь инструменты для организации взаимодействия между объектами. В этом и есть смысл ООП — не просто создать кучу объектов, а сделать так, чтобы они эффективно взаимодействовали друг с другом. И в данном случае эффективность — это не скорость исполнения программы, а
возможность вносить правки в код максимально быстро, при этом не переписывая тонны кода.
Как я уже отмечал, с «движком для конфигов на LiketEngine» что-то не так. У него есть проблема — хоть он и написан на объектно-ориентированном языке (на самом деле Java уже давно не ОО, а мультипарадигма), он не ОО. В нём нет той самой расширяемости — если пользователь захочет добавить новую команду, ему придётся изменить реализацию класса CommandShell. А это нарушает несколько принципов SOLID:
- Single responsibility principle (принцип единственной обязанности) — CommandShell берёт на себя слишком много обязанностей: тут и парсинг, и идентификация команд, и их исполнение. Добавление новой команды, изменение процедуры парсинга потребуют внесения правок в код класса...
- Open/closed principle (принцип открытости/закрытости) — ... что может привести к изменению интерфейса, а следовательно и логике работы с экземплярами класса CommandShell. Помимо изменения самого класса, нам потребуется изменить и клиентский код.
Я не хочу досконально разбирать ни этот «движок», ни сам LiketEngine, поэтому покажу архитектурное решение, которое бы позволило избежать вышеупомянутых проблем, на синтетическом примере. Возьмём некий «текстовый процессор», а именно его систему команд.
Система командЗдесь нам придумывать что-то новое и оригинальное не нужно — достаточно полистать книжку по паттернам проектирования и найти Command (он же Action, он же Transaction).
Основная цель паттерна — инкапсулировать логику команды в отдельном классе. Для чего это нужно? Во-первых, мы минимизируем количество кода в нашем «исполнителе», а во-вторых мы сможем реализовать историю команд (одна из причин, по которой я не стал брать LiketEngine в качестве примера), но об этом чуть позже. И перейдём уже к коду.
Основное назначение текстового процессора — это (не удивляйтесь) редактирование текста. Поэтому определим класс Text.
Интерфейс команды:
Реализуем несколько команд для нашего игрушечного текстового процессора: добавление текста и замена (RegEx, конечно же).
Всё. Мы только что реализовали простейшую систему команд. Вот пример того, как она работает:
История командЕсли в редакторе нельзя отменить команду — это плохой редактор. Исправим это.
Для начала определим интерфейс для команд, которые можно отменить:
И слегка перепишем наши команды:
И класс который будет непосредственно управлять историей команд:
Использование:
ВишенкаИ вишенка на торте — лямбды. К интерфейсу Executable можно добавить аннотацию @FunctionalInterface и вместо объектов использовать анонимные функции (только в Java >8, конечно же).
Code smellsИ на этом можно было бы закончить, если бы не одно «но» — оператор instanceof. Для ООП такой оператор сродни goto для процедурных языков. Не то, чтобы его нельзя было использовать, но пихать его всюду — идея не лучшая. По крайней мере, не в этом случае.
Исправить данную оплошность нам поможет так называемая «двойная диспетчеризация» — просто перенесём обязанности по добавлению в стек на сами команды.
Для это подправим интерфейс Executable...
... CommandManager...
... и команды.
Заключение
возможность вносить правки в код максимально быстро, при этом не переписывая тонны кода.
Как я уже отмечал, с «движком для конфигов на LiketEngine» что-то не так. У него есть проблема — хоть он и написан на объектно-ориентированном языке (на самом деле Java уже давно не ОО, а мультипарадигма), он не ОО. В нём нет той самой расширяемости — если пользователь захочет добавить новую команду, ему придётся изменить реализацию класса CommandShell. А это нарушает несколько принципов SOLID:
- Single responsibility principle (принцип единственной обязанности) — CommandShell берёт на себя слишком много обязанностей: тут и парсинг, и идентификация команд, и их исполнение. Добавление новой команды, изменение процедуры парсинга потребуют внесения правок в код класса...
- Open/closed principle (принцип открытости/закрытости) — ... что может привести к изменению интерфейса, а следовательно и логике работы с экземплярами класса CommandShell. Помимо изменения самого класса, нам потребуется изменить и клиентский код.
Я не хочу досконально разбирать ни этот «движок», ни сам LiketEngine, поэтому покажу архитектурное решение, которое бы позволило избежать вышеупомянутых проблем, на синтетическом примере. Возьмём некий «текстовый процессор», а именно его систему команд.
Система командЗдесь нам придумывать что-то новое и оригинальное не нужно — достаточно полистать книжку по паттернам проектирования и найти Command (он же Action, он же Transaction).
Основная цель паттерна — инкапсулировать логику команды в отдельном классе. Для чего это нужно? Во-первых, мы минимизируем количество кода в нашем «исполнителе», а во-вторых мы сможем реализовать историю команд (одна из причин, по которой я не стал брать LiketEngine в качестве примера), но об этом чуть позже. И перейдём уже к коду.
Основное назначение текстового процессора — это (не удивляйтесь) редактирование текста. Поэтому определим класс Text.
- public class Text {
- private String _text;
- public Text(String initialText) {
- _text = initialText;
- }
- public void setText(String text) {
- _text = text;
- }
- public String getText() {
- return _text;
- }
- }
Интерфейс команды:
- /**
- * Исполняемая команда.
- */
- public interface Executable {
- /**
- * Исполняет команду.
- */
- void execute();
- }
Реализуем несколько команд для нашего игрушечного текстового процессора: добавление текста и замена (RegEx, конечно же).
- /**
- * Команда, добавляющая текст в конец исходной строки.
- */
- public class Append implements Executable {
- private Text _text;
- private String _appended;
- /**
- * Создаёт команду Append.
- * @param text редактируемый текст
- * @param appended добавляемая строка
- */
- public Append(Text text, String appended) {
- _text = text;
- _appended = appended;
- }
- @Override public void execute() {
- _text.setText(_text.getText() + _appended);
- }
- }
- /**
- * Команда, производящая замену по регулярному выражению.
- */
- public class Replace implements Executable {
- private Text _text;
- private String _pattern;
- private String _replace;
- /**
- * Создаёт команду Replace.
- * @param text редактируемый текст
- * @param pattern регулярное выражение
- * @param replace замена
- */
- public Replace(Text text, String pattern, String replace) {
- _text = text;
- _pattern = pattern;
- _replace = replace;
- }
- @Override public void execute() {
- Pattern p = Pattern.compile(pattern);
- String result = p.matcher(_text.getText())
- .replaceAll(_replace);
- _text.setText(result);
- }
- }
Всё. Мы только что реализовали простейшую систему команд. Вот пример того, как она работает:
- private static void sout(Text text) {
- System.out.println(text.getText());
- }
- Text t = new Text("");
- new Append(t, "Hello World").execute();
- sout(t);
- new Replace(t, "World", "Everypony").execute();
- sout(t);
- > Hello World
- > Hello Everypony
История командЕсли в редакторе нельзя отменить команду — это плохой редактор. Исправим это.
Для начала определим интерфейс для команд, которые можно отменить:
- /**
- * Отменяемая команда.
- */
- public interface Undoable {
- /**
- * Отменяет команду.
- */
- void undo();
- /**
- * Повторяет команду.
- */
- void redo();
- }
И слегка перепишем наши команды:
- public class Append implements Executable, Undoable {
- ...
- private String _memento;
- public Append(...) {
- ...
- _memento = text.getText();
- }
- ...
- @Override public void undo() {
- _text.setText(_memento);
- }
- @Override public void redo() {
- execute();
- }
- }
- public class Replace implements Executable, Undoable {
- ...
- private String _memento;
- public Replace(...) {
- ...
- _memento = text.getText();
- }
- ...
- @Override public void undo() {
- _text.setText(memento);
- }
- @Override public void redo() {
- execute();
- }
- }
И класс который будет непосредственно управлять историей команд:
- public class CommandManager {
- private Deque<Undoable> _undoStack;
- private Deque<Undoable> _redoStack;
- public CommandManager() {
- _undoStack = new ArrayDeque<>();
- _redoStack = new ArrayDeque<>();
- }
- /**
- * Исполняет переданную команду и записывает её в историю команд, если она отменяемая.
- */
- public void execute(Executable cmd) {
- cmd.execute();
- if (cmd instanceof Undoable) {
- _undoStack.push((Undoable)cmd);
- _redoStack.clear();
- }
- }
- /**
- * Отменяет последнюю команду.
- */
- public void undo() {
- Undoable cmd = _undoStack.pop();
- cmd.undo();
- _redoStack.push(cmd);
- }
- /**
- * Повторяет отменённую команду.
- */
- public void redo() {
- Undoable cmd = _redoStack.pop();
- cmd.redo();
- _undoStack.push(cmd);
- }
- }
Использование:
- CommandManager cmd = new CommandManager();
- Text t = new Text("");
- cmd.execute(new Append(t, "Hello World"));
- sout(t);
- cmd.execute(new Replace(t, "World", "Everypony"));
- sout(t);
- cmd.undo();
- sout(t);
- cmd.redo();
- sout(t);
- > Hello World
- > Hello Everypony
- > Hello World
- > Hello Everypony
ВишенкаИ вишенка на торте — лямбды. К интерфейсу Executable можно добавить аннотацию @FunctionalInterface и вместо объектов использовать анонимные функции (только в Java >8, конечно же).
- cmd.execute(() -> t.setText("Lambdas are awesome!"));
- > Lambdas are awesome!
Code smellsИ на этом можно было бы закончить, если бы не одно «но» — оператор instanceof. Для ООП такой оператор сродни goto для процедурных языков. Не то, чтобы его нельзя было использовать, но пихать его всюду — идея не лучшая. По крайней мере, не в этом случае.
Исправить данную оплошность нам поможет так называемая «двойная диспетчеризация» — просто перенесём обязанности по добавлению в стек на сами команды.
Для это подправим интерфейс Executable...
- public interface Executable {
- void execute(CommandManager cmdManager);
- }
- public class CommandManager {
- ...
- public void execute(Executable cmd) {
- cmd.execute(this);
- }
- public void addUndoable(Undoable cmd) {
- _undoStack.push(cmd);
- _redoStack.clear();
- }
- ...
- }
... и команды.
- public class Append implements Executable, Undoable {
- ...
- @Override public void execute(CommandManager cmdManager) {
- doImpl();
- cmdManager.addUndoable(this);
- }
- private void doImpl() {
- // Логика команды
- }
- ...
- @Override public void redo() {
- doImpl();
- }
- }
Заключение