Содержание статьи
Большая часть современного ПО разрабатывается группами программистов. Кто-то отвечает за пользовательский интерфейс, кто-то за ядро, а кто-то за дополнительные модули. Чтобы работа всех этих людей не пропала даром, нужно грамотно объединить разные части проекта, не забыв при этом о возможном его расширении. Для этого нам пригодится паттерн проектирования «Команда», который инкапсулирует в себе исполнителя задачи и ее условия.
Давай представим, что мы работаем в компании, которая пишет сложный многокомпонентный софт. Проект узкоспециализированный и имеет много разнообразных модулей, количество которых может со временем расширяться. Наша задача состоит в том, чтобы написать гибкую и удобную систему горячих клавиш для этой программы. Набор возможных сочетаний ограничивается хоткеями от <ctrl-0> до <ctrl-9>, а также включает в себя комбинацию ctrl-z для отмены действия.
Казалось бы, всё просто: накодил большой блок switch, который при нажатии разных сочетаний кнопок вызывает ту или иную функцию какого-либо модуля, и радуйся. Но, во-первых, такой подход не отличается гибкостью. Когда проект пополнится новыми модулями или hotkeys, нам придется менять код этого раздутого оператора ветвления, из-за чего он впоследствии раздуется еще больше. А во-вторых, начальство большими красными буквами написало, что у пользователя должна быть возможность переназначить эти горячие клавиши. Таким образом, жестко забить команды в код switch у нас точно не выйдет.
Взглянув на код модулей проекта и списки их команд, которые можно присвоить хоткеям, мы еще больше убеждаемся, что придется изрядно помучиться, прежде чем мы придумаем более-менее рабочую архитектуру всего этого.
Интерфейсы модулей, доступные для обращения через HotKeys
class Calculator
{
public:
void runCalc();
void closeCalc();
}
class Printer
{
public:
void printDocument();
void printImage();
void printEmail();
}
class Browser
{
public:
void runBrowser();
void closeBrowser();
}
// И дальше много всяких разных классов
Паттерн «Команда»
Отбросив лирику, перейдем к изучению паттерна «Команда», который должен помочь нам в этом нелегком деле. Для начала следует разобраться, что мы имеем. У нас есть множество модулей с самыми разнообразными API. Также у нас есть окошко, в котором пользователь может выбрать из списка одну из команд, предоставляемых модулями, и закрепить ее за определенным сочетанием клавиш.
Формально выражаясь, обработчик нажатий клавиатуры — это клиент, API-функции модулей, вызываемые с помощью горячих клавиш, — это задачи, а модули, предоставляющие эти задачи, — это исполнители. Окошко с настройками hotkeys представляет собой некий посредник между клиентом и исполнителем, скрывающий все детали выполняемых операций. Такая структура позволяет полностью отделить клиент от исполнителя, то есть пользователь понятия не имеет, какой модуль работает при нажатии той или иной комбинации клавиш и что он делает. И это очень хорошо, так как чем лучше изолированы друг от друга части кода, тем надежней работает программа.
Для такой изоляции нам нужно определить объект команды, а следовательно, и соответствующий интерфейс. Он достаточно прост и определяет всего один метод execute(), который должен выполнять метод какого-либо из модулей.
Интерфейс объекта «Команды»
class Command
{
public:
void execute() = 0;
}
Для определения конкретного объекта мы просто объявляем новый класс, который наследует интерфейс Command, и определяем его метод execute(). Допустим, мы хотим создать команду, запускающую калькулятор. Для этого мы создадим класс RunCalcCommand, который будет наследовать интерфейс Command, и переопределим execute() для вызова метода runCalc() модуля Calculator.
Команда запуска калькулятора
class RunCalcCommand: public Command
{
Calculator *calc;
public:
RunCalcCommand(Calculator *excalc)
{
calc = excalc;
}
void execute()
{
calc->runCalc();
}
}
Если внимательно присмотреться к коду команды, то можно заметить, что в конструкторе класса RunCalcCommand передается указатель на модуль Calculator. Это сделано для большей гибкости. В будущем у нас может появиться класс ModernCalculator, вызывающий продвинутую версию расчетной программы. Используя композицию, то есть не фиксируя исполнителя жестко в коде, мы увеличиваем изолированность кода, что в будущем позволит сэкономить время.
Хакер #157. Деньги на багах в Chrome
Теперь нужно связать команду с хоткеем. Для этого можно создать массив указателей на объекты класса Command. Размер массива будет равен количеству поддерживаемых горячих клавиш. Каждый элемент массива команд сопоставляется с определенным хоткеем. В нашем случае для этого можно преобразовать значение кнопки, которая вместе с Ctrl составляет какое-либо из возможных сочетаний, в числовое значение. Если бы мы использовали сочетания, например, от ctrl-a до ctrl-k, то нам бы потребовался немного другой подход.
Получив код нажатого сочетания клавиш и преобразовав его в соответствующий индекс для массива команд, мы можем смело вызывать метод execute() объекта, указатель на который находится в нужной ячейке массива.
Назначение команды на hotkey и ее запуск
// Код инициализации команды и хоткея
const int comCount = 10;
Command* commands[comCount];
Calculator *calc = new Calculator();
commands[0] = new RunCalcCommand(calc);
// Код в обработчике нажатий клавиатуры
// Получаем нажатые клавиши
hotkey = catchHotKey();
// Преобразовываем их в индекс и запускаем команду
int index = hotkey2index(hotkey);
commands[index]->execute();
Всё довольно-таки просто. Можно определить еще несколько наследников Command, которые будут выполнять определенные действия, и связать их с горячими клавишами. Такая архитектура позволяет полностью отделить клиент от исполнителей. Обработчик клавиатуры понятия не имеет, какой модуль обрабатывает команду и что именно он делает, а модули, в свою очередь, не подозревают, что обращение к ним осуществляется с помощью hotkeys, а не каким-то другим способом.
Отмена команды
Вроде бы всё хорошо, но мы совсем забыли про отмену. Сочетание клавиш ctrl-z должно откатывать действие последней команды. Реализовать отмену довольно просто, хотя на первый взгляд может показаться, что это совсем не так. Для этого мы немного изменим интерфейс Command.
Класс Command, поддерживающий отмену
class Command
{
public:
void execute() = 0;
void undo() = 0;
}
class RunCalcCommand: public Command
{
Calculator *calc;
public:
RunCalcCommand(Calculator *excalc)
{
calc = excalc;
}
void execute()
{
calc->runCalc();
}
void undo()
{
calc->closeCalc();
}
}
Мы просто добавили метод undo(), который должен быть переопределен в классах-наследниках. Программист сам решает, какую именно функцию модуля будет использовать метод отмены. Так, метод undo() для RunCalcCommand будет обращаться к функции closeCalc() модуля Calculator. Нам также потребуется немного подправить код обработчика клавиатуры.
Обработчик клавиатуры с поддержкой отмены
// Код инициализации команды и хоткея
const int comCount = 10;
Command* commands[comCount];
Command *lastCommand = new NoCommand();
Calculator *calc = new Calculator();
commands[0] = new RunCalcCommand(calc);
// Код в обработчике нажатий клавиатуры
// Получаем нажатые клавиши
HotKey *hotkey = catchHotKey();
// Если это отмена, то вызываем соответствующий метод
if (hotkey->str() == "ctrl-z")
{
lastCommand->undo();
}
// Обработка остальных сочетаний
Здесь мы просто запоминаем в переменной lastCommand указатель на последнюю использованную команду и при нажатии ctrl-z вызываем соответствующий метод. Дополнительно мы прибегаем к небольшому трюку, используя объект пустой команды NoCommand. Код этого класса выглядит так:
Пустая команда NoCommand
class NoCommand: public Command
{
public:
void execute() {};
void undo() {};
}
Такие объекты-заглушки используются довольно часто. Они нужны, чтобы уменьшить количество проверок нулевого указателя. Если бы lastCommand был равен NULL, то перед вызовом метода undo() нам пришлось бы проверять корректность значения этого указателя, что нежелательно, так как однажды мы можем забыть это сделать, в результате чего программа с грохотом упадет. Такие же объекты-заглушки рекомендуется использовать и для остальных хоткеев, которым не назначены соответствующие команды.
Кстати, код обработчика клавиатуры можно модифицировать так, чтобы он поддерживал отмену не только последней операции, но и вообще всех цепочек выполненных команд. Для этого вместо простого указателя на последнюю команду следует использовать стек. При обработке какого-либо хоткея в стек будет добавляться указатель на соответствующую команду. Таким образом, мы получим полную историю вызовов команд, что позволит нам последовательно все отменить при помощи ctrl-z.
Макрокоманды
Макросы — одно из величайших изобретений человечества, помогающее ему экономить тонны времени. Наш паттерн позволяет реализовывать макрокоманды всего лишь с помощью нескольких дополнительных строк кода. Для начала определим класс, отвечающий за логику группового выполнения операций.
Макрокоманда
class MacroCommand: public Command
{
Command *commands;
int comCount;
public:
MacroCommand(Command *comArray, int elemCount)
{
commands = comArray;
comCount = elemCount;
}
void execute()
{
for (int i = 0; i < comCount; i++)
{
commands[i]->execute();
}
}
void undo()
{
for (int i = 0; i < comCount; i++)
{
commands[i]->undo();
}
}
}
Как видно, класс MacroCommand является наследником Command и переопределяет всё те же методы execute и undo. В интерфейсе от обычной команды он отличается лишь конструктором. Этот класс принимает не указатель на исполняющий модуль, а массив указателей на простые команды. Код execute() просто проходит по элементам массива и вызывает каждый из них. Так же ведет себя и undo(). Обработчик клавиатуры при обращении к объекту команды понятия не имеет, макрос это или обычная единичная операция, — главное, что все они предоставляют функции execute() и undo().
Расширенные возможности паттерна «Команда»
Наш паттерн можно использовать не только для обработки горячих сочетаний клавиш. С помощью него можно организовывать очереди запросов. Допустим, что у нас есть пул потоков, который должен выполнять некоторые задания. Все задания представляют собой объекты, реализующие уже знакомый нам интерфейс Command. Команды выстраиваются в очередь, к которой последовательно обращаются потоки. Они забирают команды из этой очереди и запускают их методы execute(). Потокам не важно, что делают эти объекты — главное, чтобы они поддерживали вызов execute(). Команды можно сохранять на жестком диске и восстанавливать их оттуда. Для этого следует немного расширить их базовый интерфейс.
Command с поддержкой сохранения и загрузки
class Command
{
public:
void execute() = 0;
void undo() = 0;
void load() = 0;
void store() = 0;
}
Метод load() предназначен для сохранения команды в журнале, а store() — для ее восстановления оттуда. Код этих методов может использовать механизмы сериализации языка программирования, если таковые в нем есть. Такая функциональность нужна для работы с большими объемами данных, которые невозможно сохранять после совершения с ними каждого действия. При сбое программы мы сможем загрузить сохраненные команды и последовательно применить их к имеющейся копии данных для приведения этих данных в актуальное состояние.
Заключение
С помощью паттерна «Команда» нам удалось полностью отделить разношерстные модули-исполнители от клиента — обработчика клавиатуры. Если в будущем в программе появятся новые модули, мы сможем легко добавить соответствующие команды и назначить им горячие клавиши. Это будет простое, изящное и эффективное решение.