Реализация операции отмены с обеспечением безопасности данных

Немного рекламы

Оглавление

Введение
Реализация операций отмены и возврата
Безопасность данных
Итоги
Комментарии

Введение

В интернете довольно много статей о реализации операций отмены. В основном все реализации сводятся в паттерну Command, что, в общем-то, логично и удобно. Но чаще всего при описании системы отмены не учитывается тот факт, что данные, с которыми работают команды, не мешало бы защитить от внешних воздействий таким образом, чтобы изменять их могли бы только с помощью команд. Ведь вполне возможна ситуация, что данные могут быть случайно изменены между вызовами команд.

В этой статье рассказывается о том, как можно обезопасить себя от таких ошибок и рассматривается взаимодействие классов, реализующих операции отмены-возврата, и класса, который хранит данные. В качестве языка программирования используется C# 2.0, однако ничто не мешает реализовать подобный подход и на других языках.

Скачать пример программы можно отсюда.

Реализация операций отмены и возврата

Описание примера

Все дальнейшее описание будет построено на одном простом примере. Пусть есть графическое приложение, претендующее на лавры фотошопа, которое умеет делать всего три операции:

  • Вывести фотографию собачки
  • Нарисовать в левом верхнем углу красный квадрат
  • Нарисовать в том же углу черную окружность

Эти действия могут быть выполнены с помощью кнопок на панели инструментов в любой последовательности, причем каждое действие может быть отменено. На следующей картинке приведен скриншот будущей программы. Картинки для кнопок были взяты из среды разработки Eclipse.

Команды

Для начала поговорим о том как, в принципе, можно реализовать отмену. Для этого очень удобно использовать паттерн, который в книге Банды Четырех назван Command. Каждое действие, которое можно совершить над данными, реализуется в виде отдельного класса. Причем каждый класс-действие (команда) происходит от одного базового класса, а в C# в качестве базового класса лучше всего использовать интерфейс.

Рассмотрим диаграмму классов команд. Начнем с интерфейса.

В качестве базового класса у выступает интерфейс ICommand, который имеет следующие методы:

  • Run() - Выполнение команды. Именно здесь происходит изменение данных, когда пользователь нажимает на соответствующую кнопку для рисования какого-либо объекта (картинки, квадрата или окружности)
  • Undo() - Отмена команды. Здесь данные возвращаются на один шаг назад.
  • Save() - Вспомогательный метод, внутри которого данные сохраняются, для того, чтобы в будущем их можно было бы восстановить, когда нужно будет выполнить отмену.

Диаграмма классов команд выглядит следующим образом:

В данном примере есть три класса команд:

  • LoadImage - загрузить и нарисовать картинку с собачкой.
  • DrawCircle - нарисовать окружность.
  • DrawRect - нарисовать квадрат.

Конкретная реализация этих классов пока не важна, считаем, что они просто есть, как суслик :).

Теперь необходимо разобраться с тем, где будут храниться классы выполненных команд. Для этого в программе существует класс UndoRedoManager.

Внутри этого класса имеется два списка типа List<ICommand>:

  • _undoList - список выполненных команд, которые можно отменить.
  • _redoList - список уже отмененных команд, которые можно вернуть.

На самом деле эти списки будут работать в по принципу стека или FILO (First In - Last Out, первый вошел, последний вышел), но для простоты реализации в программе используется обычный список.

Класс UndoRedoManager

Класс UndoRedoManager работает следующим образом. Если необходимо выполнить новую команду, то вызывается метод RunCommand(), которому в качестве параметра передается экземпляр класса, реализующий интерфейс ICommand:

public void RunCommand (ICommand command) { ... }

Внутри метода RunCommand() выполняются следующие действия:

  1. Переданная в качестве параметра команда добавляется в список _undoList.
  2. Список отмененных команд _redoList очищается. То есть после выполнения новой команды мы не сможем вернуть отмененную до этого команду.
  3. Выполняется метод Save() переданной в качестве параметра команды, чтобы команда могла сохранить предыдущее значение данных.
  4. Выполняется метод Run() переданной в качестве параметра команды, то есть происходит ее выполнение.

Отмена с почки зрения пользователя выполняется с помощью метода Undo() класса UndoRedoManager:

public void Undo () {...}

Отмена состоит из следующих шагов (разумеется, что эти операции можно проделать, только если есть команды, которые можно отменить, то есть список _undoList не пустой):

  1. Последняя добавленная в список _undoList команда переносится из списка _undoList в список _redoList. Именно ее и необходимо будет отменить. Из _undoList команда удаляется.
  2. Вызывается метод Undo() отменяемой команды.

Если список отмененных команд _redoList не пустой, то с помощью вызова метода Redo() класса UndoRedoManager можно вернуть только что отмененную команду.

public void Redo () {...}

Алгоритм выполнения этого метода следующий:

  1. Последняя отмененная команда из списка _redoList переносится обратно в список _undoList.
  2. Выполняется метод Save() перенесенной в список _undoList команды.
  3. Выполняется метод Run() перенесенной в список _undoList команды.

На следующих рисунках показаны состояния списков _undoList и _redoList после серии выполнения и отмен команд.

Начальное состояние

Выполнена команда Command1

Выполнены еще три команды Command2... Command4

Выполнена отмена последней команды Command4

Выполнена отмена следующей команды Command3

Выполнен возврат последней последней отмененной команды Command3

Выполнена новая команда Command5

В конце этого раздела надо сказать о том, как команды могут хранить состояние данных, которые были до выполнения команд, с тем, чтобы потом можно было бы осуществить отмену.

Самый простой способ, который и используется в тестовом примере, это хранить в каждой команде полностью копию данных, которые были до выполнения команды. Иногда этот способ оказывается единственно возможным, но он имеет один существенный недостаток - при этом резко возрастает объем используемой оперативной памяти, особенно, если данные сами по себе объемные. Если все же необходимо хранить объемные данные для каждой команды, то есть смысл организовать что-то вроде кэша на жестком диске, тогда в команде достаточно хранить указатель на файл или смещение в файле кэша, откуда в любой момент команда сможет прочитать необходимую информацию для выполнения отмены. Но при этом, разумеется, операция отмены будет выполняться заметно дольше, как как данные придется считывать из файла.

Более рациональный способ состоит в том, чтобы хранить в команде (или опять же в кэше) только ту часть данных, которая изменяется. Например, в нашем примере квадрат и окружность рисуются только в левом верхнем углу рисунка, поэтому этим командам вполне было бы достаточно хранить только часть картинки, которая приходится на левый верхний угол. Этот способ уже менее требовательный к оперативной памяти, особенно если команды изменяют небольшую область данных.

Третий способ сохранения состояния данных является еще более рациональным, но использовать его можно далеко не всегда, и при этом надо быть более аккуратным при выполнении отмены. Суть этого способа состоит в том, что иногда команды представляют собой обратимые операции, для отмены которых достаточно запомнить параметры самой команды, а при отмене произвести действия, обратные выполненным.

Для примера, пусть данные представляют собой очень большой массив из целых чисел, а команда только прибавляет к каждому элементу какую-нибудь константу. Тогда для отмены не обязательно надо хранить весь объем данных, а достаточно пройтись по массиву и вычесть из каждого элемента эту константу. К сожалению, далеко не все операции обратимы, особенно это хорошо видно на примере данных, представляющих собой картинку в виде массива RGB, где каждый компонент цвета представлен одним байтом. Если к какой-то картинке мы будем прибавлять другой цвет (или, как вариант, делать ее более светлой), то могут начать появляться области, в которых значение компонент цвета равно 255 и больше увеличиваться не сможет, и начнет теряться информация о цвете. Однако при такой отмене мы будем вычитать константу именно из 255, и получим уже не ту картинку, которая была у нас до этого. Поэтому для этого случая такой способ отмены неприменим.

Безопасность данных

Как правило, в программах, реализующих возможности отмены-возврата, данные должны изменяться только с помощью команд. Но в то же время доступ к данным для чтения должны иметь и другие классы, например, чтобы отображать хранимые данные.

То есть, по сути, класс для данных должен быть составлен из двух частей - часть методов (и свойств) должна быть доступна всем, а часть методов, с помощью которых данные могут быть изменены, должна быть доступна только для команд (и еще для класса UndoRedoManager, но это вызвано технической необходимостью). Кроме того, любой класс, который имеет доступ к документу на чтение, должен иметь возможность изменять данные с помощью команд, так как наша цель - не ограничить полностью на изменение данных в принципе, а ограничить непосредственное изменение данных, которое не может быть в будущем отменено.

Для реализации этой возможности можно использовать наследование. В базовом классе (названном DocumentReader) будут содержаться методы, которые должны быть доступны всем, то есть методы для чтения. В производном класса (названном просто Document) содержатся методы, с помощью которых данные можно изменить.

Рассмотрим диаграмму этих классов:

Внутри абстрактного класса DocumentReader хранятся данные в protected-члене _data. Метод GetData() возвращает эти данные (а лучше их копию, чтобы их не могли изменить вовне по указателю).

Также внутри класса DocumentReader содержится экземпляр класса UndoRedoManager - _manager. Именно через него выполняются два других метода DocumentReader - Undo(), Redo() и RunCommand().

Производный класс Document является очень простым. Во-первых, в нем появляется метод SetData(), который и изменяет данные, после чего вызывает соответствующее событие, но это в данный момент не так важно. А, во-вторых, именно в конструкторе класса Document создается экземпляр класса UndoRedoManager, причем в качестве параметра конструктора используется указатель this на сам класс Document. Таким образом экземпляр класса UndoRedoManager получает указатель на экземпляр класса Document и может передать его в класс команды.

Здесь придется вернуться назад, к интерфейсу ICommand и сказать, что в качестве параметров методы интерфейса Save(), Run() и Undo() ожидают указатель на класс Document. Класс UndoRedoManager и хранит внутри себя этот указатель, который в будущем будет передан командам. То есть после создания экземпляра класса Document можно спокойно забыть, что это именно Document, а не DocumentReader.

В основной программе можно создать экземпляр класса для хранения данных следующим образом:

DocumentReader _doc = new Document ();

и в дальнейшем спокойно пользоваться классом Document, не опасаясь того, что кто-то по ошибке вызовет метод SetData(). Но зато мы всегда сможем использовать метод RunCommand(), если нам нужно будет изменить данные.

Разумеется, такой способ не защитит от явного преобразования типа к Document, но такие явные преобразования вообще зло, и ответственность за безопасность данных в данном случае будет лежать на том, кто такое преобразование выполнял.

Итоги

В заключении кратко перечислим как связаны между собой классы.

  • Основная программа имеет доступ к DocumentReader, с помощью которого можно считывать данные и выполнять комманды.
  • DocumentReader хранит указатель на UndoRedoManager.
  • UndoRedoManager хранит указатель на Document.
  • UndoRedoManager передает указатель на Document в каждую выполняемую команду.
  • Через UndoRedoManager команды имеют доступ к Document.
  • Доступ к методам UndoRedoManager можно получить только через вызов методов Undo(), Redo() и RunCommand() из класса DocumentReader.
  • Доступ к экземпляру класса UndoRedoManager из класса DocumentReader получить невозможно.

Немного рекламы

Вы можете подписаться на новости сайта через RSS, Группу Вконтакте или Канал в Telegram.
5 stars

Рейтинг 4.5/5. Всего 26 голос(а, ов)



Дмитрий 25.01.2015 - 18:27

Спасибо

Очень хорошо написано, помогло разобраться с undo.


Подписаться на комментарии
Автор:
Тема:
 Ваш комментарий
 
 
Введите код 852
 

Володя 20.04.2009 - 17:08

Очень интересно

Я ищу материал по этой теме, ты толкни меня, - откуда взял ты это? Очень доходчиво и удобно написано. Есть источник или твоё? Где найти такие же точно вещи?

Прошу, ответь по почте egorow512(собачка)gmail.com

Jenyay 20.04.2009 - 20:37

Спасибо, это я сам писал.