Как стать автором
Обновить

Комментарии 44

Это все, конечно, здорово на этапе проектирования нового приложения. Тогда можно выбрать operation-oriented или value-oriented метод, и в соответствии с этим реализовывать логику приложения. Но когда вашему приложению уже 5 лет, у вас куча всевозможных действий над данными, и вдруг менеджер проекта решает, что пора бы вам прикрутить Undo/Redo, то тот самый плохой 3-й способ может быть единственным решением, без переписывания всего приложения.
Это означает, что 5 лет назад этап проектирования был пропущен.
Это означает, что 5 лет назад принимающие решение люди таки решили, что этот функционал не нужен.
Да неее, несогласен, это просто означает, что 5 лет назад 1) скорее всего, никто просто не знал, как делается undo 2) все думали, что «щас не до undo, undo прикрутим как-нибудь потом».

А потом выясняется, что это одна из вещей, о которых надо было позаботиться заранее, причём при наличии умений это лишь минимально увеличивает стоимость всей реализации.

А самая, по-моему, частая фича из числа тех, что часто оставляют на «прикрутим потом», а потом тяжко раскаиваются — распределение прав доступа :-)
При этом даже не нужно было думать, нужен или нет undo, достаточно было просто разделить интерфейс, операции и данные, то есть применить старый добрый MVC. Далеко не все способны сделать это, особенно, если вместе стекаются сторожилы, программировавшие на коболе и новички, изучившие «C++ за 21 день»
Бывают просто «внезапные хотелки» нетехнических управленцев, но имеющих право. Был у нас, к примеру, крупный проект на MySQL. Который рос, развивали и продавался не один год. И вот ВНЕЗАПНО, один управленец где-то прочитал, что есть PostgreSQL и в ультимативной форме потребовал переделать всю систему на неё. Месяца два ушло на то, чтобы отстоять использование готового решения.

Другой случай — есть железо, которое работает в GSM сетях по CSD или GPRS, шлёт пакеты длинной до 255 байт. И не в постоянном режиме, а по запросу с верхнего уровня или спорадически. Опять-же ВНЕЗАПНО руководству захотелось 3G. Типа это модно, современно и быстрее. Доказать, что для нашего трафика никакой разницы нет не удалось. Пришлось искать подходящий нам модем с 3G. Само-собой pin-to-pin совместимых не нашлось, соответственно переразводка платы, доработка напильником прошивки, испытания (в том числе климатические) и прочие радости.
Согласитесь, подобные хотелки неуместно сравнивать с добавлением базиса для фундаментальных фич современного ПО.
Тут как-раз такая ситуация, что переносное устройство размером со спичечный коробок по дизайну запитывают от БелАЗовского аккумулятора, а потом, спохватившись, добавили повышающую схему для питания от автомобильного аккумулятора. А подобные внезапные хотелки — обычный форс-мажор, такой же, как и переход госструктур на никсы. Этого нельзя было предугадать,

Что касается ваших примеров, то тут косяк главного инженера, ведущего проект. Все излишние хотелки он должен отсекать, это его работа.
Написать про value-oriented то, что он не требует пересчётов — это лукавство. Всё-таки сохраняется контрольная точка и набор изменений только в сторону redo либо в сторону undo, иначе это несколько автоматизированный Operation-oriented, в котором сложные изменения представляется в виде набора простых команд (в Qt это называется macro).
В Value-oriented может и не быть пересчётов вовсе, если сохранять все значения, даже зависимые. Но я одного не понимаю, при чём тут автоматизированный op-or?
> В Value-oriented может и не быть пересчётов вовсе, если сохранять все значения, даже зависимые
Но это третий метод же! «когда при наборе лишь одного символа сохранялся весь документ». А если сохранять все зависимые части, то есть от позиции редактирования до конца документа, то восстановление потребует-таки некоторых пересчётов, как минимум, перерисовки.
Я понимаю, что пример несколько оторванный от реальности, но…
Не настолько зависимые. Одно дело — перерисовка документа. Другое дело — пересчёт всей таблицы (да хоть Excel) после изменения ячейки. Грубо говоря, при смене одной ячейки идёт пересчёт зависимых от неё, и только они сохраняются.
Перерисовка к Undo/Redo вообще никакого отношения иметь не должна.
С этой точки зрения согласен.
Вообще, по моему ничтожному мнению, Operation-oriented метод представляет большую гибкость, нежели Value-oriented, так как в рамках операции можно сохранить состояние объекта вместе со всеми зависимостями. И даже более того, можно добавить чек-поинты и пересчёт от них, если чуть-чуть выбраться из коробки. Как это сделать в Value-oriented, я с ходу сказать не имею.
То, что op-or — более гибок, верно. Vl-or более простой вариант, ибо для op-or нужно прописывать реализацию для каждой команды.
А теперь забудьте об этом методе и более не вспоминайте, ибо это уже не Undo/Redo, а бэкапы.

Назначение хранитель(memento) создавать снимки(snapshot) состояния. Снимок может быть полный или частичный, зависит от требований и сложности предметной области.

Здесь я имел в виду, что сохранять всё и вся без такой необходимости — плохой вариант.
Есть два паттерна, которые для этих целей подходят. Memento и Command и которые по сути в статье описанны.
Об этом в статье и написано, т.е. эти два паттерна были упомянуты. Но тут более конкретная задача — Undo/Redo.
Извнияюсь, почему то в первый раз не заметил, сейчас пролистал снова и увидел.
Просто добавил информацию об этом на более видное место.

А если делать value-oriented через версионирование(как в базах данных с их MVCC), то мы бесплатно получаем full snapshot. А для уменьшения потребления оперативной памяти, можно привлечь хранение данных на диске.

Такой вариант — слишком долго и много для Undo/Redo. Я уже говорил — это больше бэкапы, а не Undo/Redo.

Как то слишком категорично.

Просто задача Undo/Redo — как можно быстрее исправить косяк пользователя, пока тот редактирует не сохранённый документ (хотя тот может пару раз сохранятся во время редактирования). Если история Undo/Redo будет именно сохранятся на диск/в БД, но в основном будет использоваться оперативная память, тогда ещё ладно. Тем не менее, хранить все косяки пользователя возможно тоже не имеет смысла. Допустим, достаточно истории в 100 действий (или можно в настройках приложения указать максимум хранимой истории). Так что не вижу смысла делать Undo/Redo с сохранением истории в БД/на диск, если для это есть… та же самая система контроля версий.

А вы мерили производительность, или делаете предположение? Есть практика документ представлять в виде дерева, что может минимизировать объем изменений. А несколько килобайт скидывается/читается очень быстро, т.к. в дисковой подсистеме ОС есть кеш, и фактически работа происходит с оперативной памятью.


Если мы говорит о редакторе документов, то кроме проблемы сохранения изменений, есть проблема отрисовки(рендеринг) с учетов шрифтов, стилей, правил переноса слов и т.д. Вот где нужна быстрота.

Еще может быть такая фигня, что объект, который был изменен 5 шагов назад, за это время изменил другой пользователь. И тогда придется показывать состояния объекта после своих и чужих изменений, и хранить изменения на сервере. А это немножко не то же самое, что хранить измененные данные в памяти локальной машины.
Верно. Но про реализацию Undo/Redo между клиентами и серверами в этой статье речь не шла.
Думаю, здесь будет уместно дать отсылку на выступление Шона Пэрента «Inheritance Is The Base Class of Evil». Он там говорит о том, как можно сделать undo за 20 минут. Это, конечно, некоторое преувеличение. Правильнее было бы сказать, что за 20 минут можно вкратце рассказать о том, как ты делал undo в течение многих дней, готовясь к своему выступлению. Но всё равно очень интересно.
ИМХО он не соврал, простой undo действительно делается за 20 минут, 19 из которых — описание команд O-o\переменных V-o в терминах топикстартера. Если не добавлять группы изменений (аля макросы), потокобезопасность, поддержку внешних коммитов и взаимного уничтожения противоречащих друг другу операций (напечатать символ стереть символ напечатать этот же символ), модельки для UI и прочее прочее, то там и делать нечего.
Удобно комбинировать все три способа. Для частых операций, меняющих небольшую часть «мира» — сделать команды, для внутренне-простых операций поддержать value-state, все остальные операции реализовать через snapshot.
Плюсы snapshot-а — объем кодирования не зависит от количества операций. Snapshot помогает сделать undo/redo сразу для всего приложения, а затем в своем темпе добавлять undo-redo-команды для часто встречающихся операций.
Интересный взгляд, и я бы ещё подумал над его корректностью :-) В своей практике я довольно давно решаю эту задачу, есть у меня и текст на Хабре на эту тему, и мне никогда Memento и Command не представлялись как равноправные паттерны для реализации Undo/Redo. Да ещё с выбором «красная или синяя», «одно или другое».

Всё-таки в книге GoF паттерн Memento описан лишь как вспомогательное средство в ситуации, когда последовательное применение do и undo не приводит к в точности исходному результату, как, например, при сдвижке объектов на диаграмме (картинка из книжки GoF):



На основе того, что приходилось делать мне, мне представляется, что попытка использовать исключительно Memento для undo приведёт к неудаче. Возможно, неслучайно «Qt такого варианта не предоставил» (но я не специалист по Qt, я по Java-части). Ну а делание снэпшотов всего состояния — это вообще ни в какие ворота, я бы даже всерьёз не стал рассматривать.

Так что может ли возникнуть ситуация, в которой годится что-нибудь ещё, кроме «Command как основное средство + Memento по необходимости» — я не знаю. Не уверен.
Вы не упомянули основной минус operation-oriented подхода. Он по сути требует реализовать x2 логики. Причём ошибки во 2-й половине кода будут всплывать при использовании только одной фичи (Undo/Redo).

При реализации паттернов с моделью, удобней реализовывать value-oriented подход на уровне модели, а остальной код покрывать транзакциями, отделяющими разные логические операции.
Это было написано, но в более общем плане. Тем не менее, я добавлю это в статью.
Извините, но говоря, что «он требует реализовать x2 логики» — Вы просто теоретизируете, или же у Вас есть реальный опыт создания системы с Undo на базе паттерна Command?

Мой личный опыт показывает, что это подход требует реализовать ну, пожалуй, x1.1 логики. И логика undo/redo настолько взаимоувязана, что никакой проблемы с расширением поля для ошибок нет. Потому что, например, do для вставки — это undo для удаления. В комментарии выше ссылка на мою статью, смотрите, например, как устроен там класс SetCellValue.
Вообще, это зависит уже от системы. Где-то проще, где-то сложнее. Бывает, что можно действительно особо не запариваться, но порой такая халатность (особенно в системах посложнее) и может привести к ошибке.
Не понял, что Вы называете «халатностью»?
Ну, к примеру, отмена удаления объекта с зависимостями вызыает пересоздание всех зависимых объектов и восстановление всех зависимостей. Далеко не всегда это просто сделать. Хотя тут в любом случае затрахаешься.
Ну ребята, ещё раз скажу — одно дело теоретизировать, а другое — на практике начать делать систему с undo. Тогда многое, казавшееся сложным, на самом деле оказывается существенно проще — и подводные камни возникают, конечно.

Если на объекте висят подчинённые объекты и зависимости, то на практике у вас либо удаление зависимости — это тоже отменяемая операция, и в составе макрокоманды при отмене все зависимости восстановятся сами собой (снова отсылаю к своей статье!), либо же всё удалённое дерево объектов будет храниться в команде — реализуя тот самый паттерн Memento.
Был опыт выпиливания большого количества полу работающего кода, размазанного по всему проекту, который должен был реализовывать логику undo, и опыт реализации этой фичи другим подходом.

Под логикой я имел ввиду бизнес логику, то есть непосредственно сам код выполняющий действия, а не весь инфраструктурный код проекта.
Если взять код из вашего проекта, указанного в предыдущей статье, например этот, то можно заметить, что сами описатели команд достаточно большие, а вот логика в них в основном занимает 1-3 строки. И столько же, а иногда и больше требуется для описания логики undo. Для меня это x2.

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

Что касается багов, то они есть всегда. Да, если вся логика занимает пару строк, ошибиться сложнее, но если проект большой или требуются нетривиальные действия, то количество кода начинает расти, а вместе с ним и количество ошибок. И на всё это ещё и юнит тесты написать нужно.

Но всё же основная проблема не в x2 кода, а в том что фича размазана по всему проекту. И в то что при тестировании, для проверки единственной фичи, нужно в 2 раза больше больше всех действий сделать. А при любой пропущеной ошибке в одной из функций отката, для пользователя ломается целиком вся фича undo.
Спасибо за подробный ответ!

Случаи бывают очень разные. Но автор статьи пишет о Command и Memento как о равноправных методах реализации, а я тут в комментариях пытаюсь отстоять, что предпочтительным подходом всегда является Command как элемент стека Undo + Memento внутри команды во вспомогательных случаях. Лично Вы делали стек Undo на Memento или на Сommand?

Я согласен с тем, что «инфраструктурный» код для undo на базе Command получается довольно громоздким. И всё же я не согласен насчёт того, что код бизнес-логики удваивается… раз уж стали смотреть мой исходник, давайте посмотрим, увеличивается ли вдвое код бизнес-логики:

Класс SetValue:

public void execute() {
	changeVal(); //там хоть 2, хоть 200 строк: используем ДВАЖДЫ
}

public void undo() {
	changeVal(); //видите? это тот же самый метод
}


Класс Insert:

public void execute() {
	internalInsert(map, num); //да будь внутри хоть 2000 строк: мы его используем ДВАЖДЫ
}

public void undo() {
	internalDelete(map, num);
}


Класс Delete:

public void execute() {
	internalDelete(map, num);
}

public void undo() {
	internalInsert(map, num);
	map.put(num, deleted); //в переменной deleted команды хранилось то, что было удалено! Если угодно, это такой квази-Memento!
}



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

Сама реализация Undo/Redo находится на уровне модели (что то вроде ORM), которая умеет делать undo и redo. А View автоматически обновляется когда в в модели что то меняется. Таким образом, когда нужна дополнительная фича, достаточно реализовать только её, undo работает автоматом.

Что касается кода вашего примера, на мой взгляд у вас просто смешан инфраструктурный код и код логики, поэтому кажется что всё это логика. Если вы вынесети работу с таблицей в отдельный класс, то окажется что вся ваша бизнес логика представлена вызовом 1-2 методов и аналогичного количества методов для undo. И если считать кодом логики именно указанное вами, то ясно видно что Undo даже больше чем основной логики.
Что касается changeVal — то вы в эту функицю добавили ещё и сохранение предыдущего значения, хотя по идее это именно логика поддержания undo.

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

Выглядит примерно так
[CmdExecute( CinematicCmd.SetStartTransform )]
public void SetStartTransformCmd( object parameters )
{
	var objs = Model.Objects
	                .Where( x => x.Type.IsGroup() && !x.Type.IsRoot() )
	                .Select( x => x.GetPropertyObject<ITransformObject>() )
	                .Where( x => x != null );

	var origin = model.OriginPosition.ToMath();
	foreach ( var q in objs )
	{
		q.SetPosition( origin );
		q.SetRotation( Quat.Identity() );
		q.SetScale( Vec3.One() );
	}
}

Что касается того какой подход предпочтителен, мне всё очень сильно зависит от задачи. Для крупных проектов, которые могу позволить себя реализацию фичи undo/redo в виде отдельной подсистемы, использующей паттерн Memento, такой подход является более предпочтительным, т.к. позволяет немного сэкономить на тестирования и поддержки кода в целом.

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

Хотя Undo к некоторым командам при использовании паттерна Сommand нельзя реализовать вовсе. Например, удаление лишнего в дереве/графе объектов, обновление/отмена обновления/повторное обновление данных из сети и т.д.
Команда кеширования графа/состояния? Абсолютно валидна.
Не поймите меня превратно, но у вас те же команды. То, что команда не хранит своё состояние и называется транзакцией — не меняет сути. Чуть сильнее разнесены MVC, иногда это правильно, иногда — нет, но, по сути, вы выполняете действия ВНУТРИ транзакции, а не ВНЕ неё. V-o подход как-раз и заключается в том, что вы в рамках транзакции производите только присваивания.

Да, всё верно, это то же применение паттерна Команда. Но только не для реализации undo/redo, а для реализации взаимодействия между View и ModelView. И в этом вся суть.

Автор статьи описывал применение паттерна Команда именно для реализации undo/redo и сравнивал с применением паттерна Хранитель этом контексте. В моём случае я использую второе, а вы первое. И у того и у другого есть свои плюсы, всё зависит от задачи.

Огромная благодарность за пример с Qt QUndoStack — получается можно отменять что угодно, не только в документах. Попробуем применить к своему случаю.

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации