Практическое применение паттернов проектирования в C#. Command Pattern

Практическое применение паттернов проектирования в C#. Command Pattern

1. Введение
Главная ошибка многих программистов, осваивающих объектноориентированный язык, — это решение задачи в лоб. Ситуация усугубляется, если до этого человек программировал на каком-либо процедурном языке или даже на том же "псевдообъектном" VB. Научить абсолютного новичка использовать предоставляемые объектноориентированным языком возможности с умом гораздо проще, особенно если под руками у него есть хорошие книги. Авторы многих руководств по VB .NET и С# имеют огромный опыт программирования на каком-нибудь C и продолжают писать по старинке и на объектноориентированных языках. Они могут бесконечно долго рассказывать вам про инкапсуляцию и полиморфизм, но на практике все, что вы увидите, — это бесконечные листинги, где через точку вызываются методы и свойства предопределенных объектов. Такие учебники могут запросто "запороть" еще не начавшихся программистов.
Некоторое время я являлся пропагандистом TVPattern на форуме сайта http://www.gotdotnet.ru (а потом плюнул — бесполезно). С помощью этого паттерна легко обходятся многие проблемы, с которыми сталкивались участники названного форума. Но, как правило, мои рекомендации попробовать TVPattern встречались в штыки — мол, много "левых" сущностей, да и интерфейс надо "перелопачивать". Тут уж, наверно, действует поговорка "Какой же русский не любит быстрой езды?!" Желание сделать все как можно быстрее (во всяком случае, субъективно) перевешивает рациональные доводы. И при каждом чихе любители быстрой езды "перелопачивают" код своей программы, вспоминая чью-то мать. Эту статью и несколько последующих я как раз и хочу посвятить именно объектноориентированному программированию с использованием паттернов проектирования. Надеюсь, мне удастся продемонстрировать ООП во всей красе, и вы по достоинству оцените мощь объектноориентированного подхода в программировании.

2. Зачем нужны паттерны проектирования?
Писать красиво на объектноориентированном языке тяжело. Программист должен хорошо продумать архитектуру своего приложения, чтобы, во-первых, она точно подходила для решения конкретной задачи, а во-вторых — оставила возможность для повторного использования написанного кода в другом проекте либо, не переписывая весь проект, адаптировать уже имеющееся приложение под изменившиеся условия или требования заказчика. Что я подразумеваю, говоря "хорошо продумать"? Мне понравилось высказывание Стива МакКоннелла (Steve McConnell) в его книге Code Complete: "The purpose of planning is to make sure that nobody starves or freezes during the trip; it isn't to map out each step in advance. The plan is to embrace the unexpected and capitalize on unforeseen opportunities". Вышесказанное можно понимать примерно так: "Цель планирования — удостовериться, что никто не голодает и не замерзает во время поездки. Это не значит продумать каждый шаг наперед, но предотвратить неожиданное и заложить фундамент для непредвиденных обстоятельств". Довольно часто программистам приходится решать аналогичные задачи. Грамотный специалист старается вновь и вновь использовать решение, которое работало на него в прошлых проектах. Как правило, некоторые отдельные блоки переписываются по нескольку раз. Дело в том, что удачно спроектировать приложение удается сравнительно редко. И если это удается, то основные идеи закрепляются в виде паттерна проектирования (design pattern). Разумеется, при правильном использовании паттернов вы как раз таки и избавляете себя от необходимости переписывания кода. По существу, получаем передачу накопленного опыта от одного программиста другому.

Как я уже говорил в своих предыдущих статьях, с помощью ООП можно с достаточной точностью описать окружающий нас мир. И сейчас вы лишний раз в этом убедитесь. Оказывается, все мы живем по заранее заготовленным шаблонам. Шаблоны нашего поведения меняются в зависимости от обстановки, в которой мы находимся. Скажем, каждый из нас знает, что для того, чтобы благополучно обойти злую собаку, нужно не обращать на нее внимания и в то же время не показывать свой страх. Как только мы видим злую собаку, мы применяем этот шаблон, не изобретая велосипед. Собираясь в ресторан или "на шашлыки", мы уже примерно знаем, что одеть и как себя вести, внося лишь некоторые коррективы, скажем, относительно погодных условий. Описание паттерна проектирования является довольно абстрактным. Вернемся к примеру со злой собакой. Нам не важно, по какой улице мы идем, порода собаки, погода, наконец. Важно только само поведение. Другими словами, на этапе описания паттерна нам все равно, в каком именно проекте будет реализован этот паттерн и какие именно объекты будут завязаны на его реализации. Главное — соответствие определенным условиям. Какую информацию мы бы хотели извлечь из описания паттерна? Во-первых, это условия, в которых действительно стоит применять паттерн, а также явные признаки того, что необходимо использовать данный паттерн. Во-вторых, нам, безусловно, необходима схема паттерна, описание каждого звена и желательно пример кода на понятном нам языке. Ну, и на сладкое хотелось бы узнать, какой эффект нас ожидает после воплощения паттерна в жизнь. Я буду излагать материал именно в таком виде.

2.1. Framework vs Design Pattern
Несмотря на то, что фреймуорк и паттерн проектирования во многом схожи, путать эти два понятия не стоит. Фреймуорк навязывает архитектуру всего приложения, причем лишь определенного типа, т.е. является более специализированным, нежели паттерн. Зачастую фреймуорк содержит несколько паттернов. Обратное условие не всегда выполнимо. У каждого программиста с опытом работы в загашнике имеются свои фреймуорки. Вот этот фреймуорк, например, для бухгалтерской программы, вот этот — для работы с портами ввода/вывода, а вот этот — для построения приложения, поддерживающего плагины и макросы. Фреймуорк можно сравнить с бланком: когда у вас уже есть основа, остается лишь добавить конкретную реализацию — заполнить поля. Из-за того, что фреймуорк определяет всю архитектуру приложения, оно может весьма чувствительно относиться к изменениям в фреймуорке. Обычно фреймуорк включает несколько паттернов, что исключает возможность перепроектирования и делает фреймуорк более гибким. Подводя черту, можно сделать вывод, что паттерны проектирования нужны для того, чтобы создать четко структурированный, легко читаемый и модифицируемый код, а самое главное — для повышения скорости разработки.

3. Command Pattern
Вы можете встретить такие названия паттернов, как Action или Transaction. На самом деле это все тот же Command. Суть Command заключается в представлении запросов в виде объектов. В результате объект, вызывающий действие, не знает ровным счетом ничего о том, как это действие будет выполняться. Согласитесь, что-то подобное нам по умолчанию предоставляет полиморфизм. Но Command — это нечто большее, ведь попутно мы можем сохранять и передавать запросы как обычные объекты. Два ключевых звена рассматриваемого нами паттерна — это классы Command и Receiver. Класс Command предоставляет интерфейс для вызова операции. Класс Receiver обладает всем необходимым для выполнения этой операции. Промежуточным звеном являются классы-наследники Command. Конкретная реализация абстрактного класса Command сохраняет Receiver как экземпляр класса и выполняет запрос к получателю, характерный для данной реализации. Уловить идею Command со слов несколько тяжеловато. Поэтому давайте на готовом примере разберем сущность этого паттерна.

3.1. Реализация undo-функциональности
Как раз в момент подготовки этой статьи на мой ящик пришла очередная рассылка от CodeProject (http://www.codeproject.com). Среди прочих недавних публикаций мне на глаза попалась статья под названием "Using the Command pattern for undo functionality". Ее автор Мэтт Бертер (Matt Berther) хочет поведать о том, как применить паттерн Com-mand для реализации undo-функциональности. Написано довольно легко для понимания. Неплохо было бы и вам ознакомиться с этим материалом. Но ввиду того, что не все знают английский или знают его не в должной степени, мы вместе с вами пробежимся по коду и разберемся в деталях реализации. Итак, сегодня мы создадим еще один клон Блокнота, который будет обладать, в отличие от оригинала, многоуровневым undo-листом. Для начала необходимо создать обертку для класса System. Windows.Forms. TextBox, т.е. не что иное, как Receiver.
public class Document { private TextBox textbox; public Document(TextBox textbox) { this.textb
ox = textbox; } public string Text { get { return textbox.Text; } set { textbox.Text = value; } } pu
blic void BoldSelection() { Text = String.Format("<b>{0}</b>", Text); } public
 void UnderlineSelection() { Text = String.Format("<u>{0}</u>", Text); } publi
c void ItalicizeSelection() { Text = String.Format("<i>{0}</i>", Text); } publ
ic void Cut() { textbox.Cut(); } public void Copy() { textbox.Copy(); } public void Paste() { textbo
x.Paste(); } }

В конструкторе сохраняем ссылку на объект, а затем реализуем необходимую функциональность правки и форматирования текста. Мы полностью изолировались от уровня представления данных. Мы ничего не знаем об элементах меню, которые будут вызывать команды, а также о самом текст-боксе, над которым будем производить различного рода операции. Если вдруг нам потребуется изменить логику какой-либо конкретной команды, то править будем именно здесь, а не в коде главной формы.
Второй шаг — описание абстрактного класса, который предоставил бы нам интерфейс для выполнения запроса к получателю (т.е. это тот самый класс Command, знакомый нам из описания паттерна). Т.к. в нашем приложении есть операции, не требующие отмены (Copy), нам потребуется два абстрактных класса. От второго класса нужно наследоваться тогда, когда мы хотим реализовать команду, которая может отменить свое действие. Если честно, я бы лучше описал два интерфейса. Но если вам нравятся абстрактные классы, то ничто не вправе вас остановить.
public abstract class Command { public abstract void Execute(); } public abstract class Undoab
leCommand: Command { public abstract void Undo(); }

Настало время заняться реализацией самих команд. Код всех команд практически идентичен, поэтому для примера приводится только одна команда. Команды как раз и являются тем барьером, который разделяет вызывающий объект (в нашем случае пункт меню) и объект, выполняющий запросы (класс Document). Сейчас это еще не очевидно, но совсем скоро все встанет на свои места.
public class BoldCommand: UndoableCommand { private Document document; private string previous
Text; public BoldCommand(Document doc) { this.document = doc; previousText = this.document.Text; } p
ublic override void Execute() { document.BoldSelection(); } public override void Undo() { document.T
ext = previousText; } }

Теперь дело за малым — написать класс, который соберет все это вместе. Назовем его Com-mandManager. Класс этот очень прост. Он обладает стеком, хранящим команды undo-листа. На тот случай, если вы не знакомы со стеком, давайте немного проясним ситуацию. Стек работает по принципу LIFO (Last-In-First-Out — последним вошел, первым вышел). Т.е. у нас есть что-то вроде коробки для теннисных мячиков. Мы не можем вынуть произвольный мячик. Для того чтобы добраться до последнего, нам необходимо сначала вынуть все предыдущие. Когда мы хотим поместить новый мячик в коробку, мы его заталкиваем (push), а когда хотим достать — вытряхиваем (pop). Эта особенность стека нам и нужна. Забегая вперед, мы будем перекладывать команды подобно книгам из одной стопки в другую (undo/ redo). Для работы со стеком в .NET Framework предназначен класс System.Collections.Stack.
public class CommandManager { private Stack commandStack = new Stack(); public void ExecuteCom
mand(Command cmd) { cmd.Execute(); if (cmd is UndoableCommand) { commandStack.Push(cmd); } } public 
void Undo() { if (commandStack.Count > 0) { UndoableCommand cmd = (UndoableCommand)commandStack.P
op(); cmd.Undo(); } } }

В стек добавляются только те команды, которые могут быть отменены. В предыдущем листинге я выделил ключевые места, характерные для Command Pattern. Мы ничего не знаем о команде — мы лишь запускаем ее на выполнение. Далее все пойдет с учетом конкретной реализации абстрактного класса Command. Точно так же и с отменой действия. У нас есть команда, и мы хотим отменить ее действие — это все нужно знать. Осталось всего лишь назначить пунктам меню соответствующие команды. Нижеследующий листинг — лишь примерный образец подключения команд. По аналогии вы напишете себе и остальной недостающий код.
public class MainForm: System.Windows.Forms.Form { private System.Windows.Forms.TextBox docume
ntTextbox; private CommandManager commandManager = new CommandManager(); private Document document; 
public MainForm() { InitializeComponent(); document = new Document(this.documentTextbox); } private 
void cutMenuItem_Click(object sender, System.EventArgs e) { commandManager.ExecuteCommand(new CutCom
mand(document)); } private void pasteMenuItem_Click(object sender, System.EventArgs e) { commandMana
ger.ExecuteCommand(new PasteCommand(document)); } }

После того, как у вас будет готов весь код, пройдитесь по нему отладчиком — это поможет вам лучше понять логику действий программы. В заключение Мэтт Бертер предлагает нам самим доработать этот пример и реализовать redo-лист, чем мы и займемся в следующей статье. Вы также увидите, как два паттерна могут переплетаться между собой. В дополнение к вышесказанному стоит отметить, что иногда бывает необходимо за раз выполнить несколько операций. Тогда нам нужно унаследоваться от класса Command, а затем последовательно вызвать несколько команд. Самое интересное — что такая макрокоманда не имеет собственного получателя. Получатель определяется теми командами, которые будут непосредственно вызываться.
public class MacroCommand: Command { private Command[] cmds; public MacroCommand(Command[] cmd
s) { this.cmds = cmds; } public override void Execute() { foreach(Command cmd in this.cmds) { cmd.Ex
ecute(); } } }

3.2. Где применяется
Паттерн Command является объектноориентированной заменой callback-методам. Вы наверняка знакомы с callback-методами — во всяком случае, если читали мою статью про веб-сервисы xml (КГ №№ 32, 37, 38). Если помните, там мы определяли сам callback-метод, затем передавали ссылку на него, а после окончания операции вызывался сам этот метод. С командами все еще проще. Передаем в качестве параметра команду, а затем после завершения вычислений вызываем ее. Опять же, мы даже понятия не имеем, что эта команда собой представляет. Даже если мы подменим команду, то вызывающий код ничего не заметит.
В одном из применений данного паттерна мы с вами непосредственно убедились, написав Блокнот с undo-листом. Предоставление запросов в виде объектов дает нам ряд неоспоримых преимуществ. Такой запрос мы можем сохранять, передавать между процессами, а также вызывать тогда, когда в этом возникнет необходимость. Также возможно вести лог изменений в системе. Безусловно, записывать каждое изменение на диск — довольно ресурсоемкое занятие. Поэтому обычно устанавливают контрольные точки. Все, что находится между этими контрольными точками, не сохраняется. Ничего не напоминает? Правильно: так, или примерно так, работает система восстановления Windows XP. После краха системы считываем с диска предыдущую контрольную точку и откатываемся до работоспособного состояния. Правда, здорово было бы прикрутить что-либо подобное к какой-нибудь бухгалтерской программе, чтобы бедная тетя Клава не рвала волосы на голове, нечаянно удалив пару десятков записей из базы данных?

3.3. Структура
Итак, для того чтобы реализовать Command Pattern, нам потребуются следующие звенья. Com-mand — определяет интерфейс для вызова команд. Command sub-class (например, BoldCom-mand) — вызывает присущий данной команде метод получателя (Receiver), наследуется от Command либо реализуется соответствующим интерфейсом. Receiver (текст-бокс — в нашем случае это обертка Document) — единственный "член семьи", который знает, как осуществить поставленную задачу. Invoker (пункты меню) — инициатор выполнения команды. Client (приложение) — присваивает инициаторам команды и устанавливает получателя. Вы можете спросить: а куда же нам отнести Com-mandManager? Думаю, это есть не что иное, как Invoker на пару с меню. Исходя из определения и названия, Invoker должен инициировать выполнение команды. В нашем случае этим как раз и занимается CommandManager. Таким образом, звено Invoker получилось у нас сосредоточенным в одном месте, а не "размазанным" по всем пунктам меню.

3.4. Результат
А результат довольно интересный. С использованием паттерна Command мы можем без труда синхронизировать пункты меню и кнопки тулбара, назначив им одну и ту же команду. Более того: команды могут подменяться динамически в зависимости от контекста использования (скажем, редактируется в данный момент текст или изображение). Ну, а про макрокоманды я уже упоминал. Все это становится возможным благодаря изолированности вызывающего объекта от объекта, действительно знающего, как выполнить ту или иную операцию и как ее отменить.

4. Рекомендую почитать
MSDN Magazine 2002: Use Design Patterns to Simplify the Relationship Between Menus and Form Elements in .NET.
Применение Command Pattern для связи кнопок на тулбаре с пунктами меню. Для MSDN FEB2003 могу сообщить прямую ссылку на статью (также верно и для "русского" MSDN): ms-help:// MS.MSDNQTR.2003FEB.1049/dnmag02/html/commandmanagement.htm. Можете скопировать эту ссылку и вставить прямо в адресную строку вашего браузера. Правда, нормально переварить данную ссылку способен только IE. Mozilla, поняв, что у нее самой ничего не получится, запустила тот же IE. А вот Opera так и вообще сказала, что знать не знает таких адресов и поддерживать их не собирается. Это я все к вопросу о выборе лучшего браузера:-).

5. Ссылки
Design Patterns by Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides (Addison-Wesley, 1995).
Using the Command pattern for undo functionality by Matt Berther, http://www.codeproject.com/article.asp?tag=8457293223938259.

2004, Алексей Нестеров
Продолжение следует


компьютерная газета


Компьютерная газета. Статья была опубликована в номере 43 за 2004 год в рубрике программирование :: разное

©1997-2024 Компьютерная газета