Привет, Хабр! В связи со своей реальной задачей проанализировать возможности Qt и .NET для реализации так называемых «Назад» (Undo) и «Вперёд» (Redo), цель которых отменить действие и отменить отмену соответственно, я решил все свои мысли, идеи и задумки развернуть в этой статье, даже если они будут частично или совсем неверными (поэтому по возможности и интересу пишите в комментарии свои замечания). Хоть и на просторах Интернета спокойно можно найти хорошие (и не очень) библиотеки и примеры реализаций, более общего представления на эти вещи я нашёл не так скоро, да и то, только в ответе на StackOverflow, а этого было мне не достаточно. Во всём найденном есть моменты, которые меня порадовали, есть и которые огорчили. Пожалуй, стоит отменить все печали и радости… чтобы к ним снова вернуться… «Назад… в будущее»!


Интересно? Добро пожаловать!


Исследование


Красная или синяя? Примерно к такому вопросу нужно будет прийти, после того, как решили реализовать в приложении Undo/Redo. Объясняю: есть два основных способа реализовать пошаговую отмену, для которых я присвоил следующие наименования: operation-oriented и value-oriented. Первый способ основан на создании операций (или транзакций), у которых есть два метода — сделать и вернуть всё как было. Второй способ не хранит никаких операций — он лишь записывает значения, которые изменились в определённый момент времени. И у первого и у второго способа есть свои плюсы и минусы.

UPD: Чтобы в дальнейшем было меньше вопросов, напомню, что Undo/Redo предназначено больше для хранения информации предыдущих вариантов документа (к примеру) во время редактирования. Записывать данные в БД или на диск будет долго, и это уже мало относится к цели Undo/Redo. Впрочем, если сильно надо — делайте, но лучше не стоит.

Метод 1: operation-oriented


Реализуется на основе паттерна «Команда» (Command).
Этот метод заключается в том, чтобы хранить операции в специальном стеке. У стека есть позиция (можно сказать, итератор), которая указывает на последнюю операцию. При добавлении операции в стек — она выполнится (redo), позиция инкрементируется. Для отмены операции стек вызывает команду undo из последней операции, а потом сдвигает позицию последней операции ниже (сдвигает, но не удаляет). Если понадобится вернуть действие — сдвиг выше, выполнение redo. Если после отмены добавляется новая операция, то есть два решения: либо заменять операции выше позиции новыми (и тогда вернуться к прежним будет невозможно), либо начинать новую «ветку» в стеке, но отсюда возникает вопрос — к какой ветке потом идти? Впрочем, ответ на этот вопрос уже искать нужно не мне, так как это зависит от требований к программе.

И так, для самого просто Undo/Redo нам нужно: базовый класс (интерфейс) с чисто виртуальными (абстрактными) функциями undo() и redo(), также класс, который будет хранить указатели на объекты, произведённые от базового класса и, конечно же, сами классы, в которых будут переопределены функции undo() и redo(). Также можно (в некоторых случаях даже очень нужно) будет сделать функции совмещения операций в одну, для того, чтобы, допустим, отменять не каждую букву по отдельности, а слова и предложения, когда буквы станут таковыми, и тому подобное. Поэтому также желательно для каждой операции присваивать определённый тип, при различии которых нельзя будет склеить операции.

И так, плюсы:
  • При правильном построении операций шансы пострадать бизнес-логике низки, так как выполняются именно операции, в которых также может быть задействована магия БЛ, только для undo нужно выполнять действия в обратном порядке, а сами действия должны быть обратными (исключая моменты, когда один объект меняется, и другие зависят от первого, тогда в таком случае в конце и undo и redo нужен будет пересчёт).
  • Менее требователен к памяти — записываются только операции, но не значения переменных. Если при операции вызывается механизм пересчёта чуть ли не всего и вся — в память эти изменения не попадают, а при отмене снова нужен будет пересчёт.
  • Более гибкий способ Undo/Redo.

Минусы:
  • Приходится реализовывать целых две функции. При неправильном построении действий в одной или обеих функциях у бизнес-логики нет и шанса на правильную работу с Undo/Redo.
  • Если операции вызывают пересчёт зависимостей и тому подобное, то такой подход будет требователен к производительности.

Также можно прочитать вот эту статью на Wiki про паттерн команд (Command), который и используется для реализации такого способа Undo/Redo, а также эту статью на Хабрахабре.

Метод 2: value-oriented


Реализуется на основе паттерна «Хранитель» (Memento).
Принцип метода — знать о всех возможных переменных, которые могут измениться, и в начале возможных изменений поставить стэк «на запись», а в конце — сделать коммит изменений.

Тем не менее, записываться должны все изменения. Если записывается только изменения, произведённые пользователем, но не записывались изменения зависимостей — то тогда при отмене/возврате зависимости останутся без изменений. Конечно, можно хитрым способом каждый раз вызывать пересчёт зависимостей, но это уже больше похоже на первый способ и удобнее тогда будет он. О способах реализации будет рассказано ниже, а пока посмотрим на достоинства и недостатки.

Плюсы:
  • Не нуждается в пересчётах — не требователен к производительности.
  • Бизнес-логика не страдает — всё подсчитанное просто снова встаёт на свои места.
  • Более простой способ Undo/Redo.

Минусы:
  • Более требователен к памяти, так как сохраняются все зависимые объекты (в противном случае либо страдает производительность, либо бизнес-логика).
  • Не способен на вызов определённых операций, так как идёт только «восстановление памяти».


Также можно прочитать вот эту статью на Wiki про паттерн Хранителя (Memento).

Плохой метод 3: full snapshot


Если что и говорить о требовательности к памяти, то этот метод будет есть очень много. Представьте ситуацию, когда при наборе лишь одного символа сохранялся весь документ. И так каждый раз. Представили? А теперь забудьте об этом методе и более не вспоминайте, ибо это уже не Undo/Redo, а бэкапы.

UPD: И нет, здесь я не имел в виду паттерн Memento, который также может сохранять кроме частичного ещё полный снимок изменений/значений. Имеется в виду, что не желательно сохранять снимок всего документа, когда изменилось лишь пару значений. Если всё-таки этого не избежать, то это скорее vl-or, а в некоторых ситуациях, когда очень редко и по сложной схеме изменяется весь документ, вы можете отказаться от записи таких изменений (сказать пользователю, что откат изменений после этой операции будет недоступен).



Способы реализации


C++: Qt


Operation-oriented


Здесь разработчики на славу постарались. С помощью Qt можно легко и просто реализовать Undo/Redo. Записывайте рецепт. Нам понадобиться: QUndoStack, QUndoCommand, а также QUndoView и QUndoGroup по вкусу. Сначала от QUndoCommand наследуем собственные классы, в которых должны быть переопределены undo() и redo(), также желательно переопределить id() для определения типа операции, чтобы потом в переопределённой mergeWith(const QUndoCommand *command) можно было проверить обе операции на совместимость. После этого создаём объект класса QUndoStack, и помещаем в него все новые операции. Для удобства, можно взять QAction *undo и QAction *redo из функций стека, которые потом можно добавить в меню, или прикрепить к кнопке. А если нужно использовать несколько стеков, тогда в этом поможет QUndoGroup, если нужно отобразить список операций: QUndoView.

Также, в QUndoStack можно отмечать clear state (чистое состояние), которые, например, может означать сохранён ли документ на диск и т.д. Вполне удобная реализация op-or undo/redo.

Я реализовал самый простой пример на Qt.
Хочу посмотреть!
Вот схема классов, к которой я пришёл (скорее всего, я сильно ошибаюсь на счёт направления стрелок...):

Здесь также упоминается некий «сервер», это на случай, если он тоже будет присутствовать и взаимодействовать с вашим приложением-клиентом. А вот и исходники (считайте, что всё писал «на коленке»).

Value-oriented


Упс… Qt такого варианта не предоставил. Даже поиск по ключевым словам «Qt memento» не дал ничего. Ну и ладно, там и такого вполне достаточно, а если не достаточно, можно воспользоваться Native'ными методами.

C++: Native


Так как в Qt не посчитали нужным добавить value-oriented Undo/Redo, поэтому нужно будет искать либо готовые реализации (где можно встретить магическое для меня слово «Memento»), либо реализовывать придётся самим. В основном всё реализуется на основе шаблонов. Всё это можно без проблем найти. Я, например, нашёл вот этот проект на GitHub. Тут реализованы сразу две идеи, можете взять и посмотреть, потестировать.

C#: .NET


Для меня C# и .NET пока что тёмные леса далёкой Сибири, но тем не менее, он мне очень и очень нужен. Поэтому стоит рассказать хотя бы о том, что мне удалось нагуглить.

Operation-oriented


Самыми хорошими примерами для меня были:
  • Хорошая статья на Хабрахабре.
  • Интересный пост про паттерн команд на .NET.
  • И просто хороший пример Undo/Redo с использованием Generics.


Вскоре нашлась и такая вот старая статья.

Быть может, что-то сможете найти и вы, а возможно на основе этого взять и написать свой велосипед гениальный код. Дерзайте.

Value-oriented


Вообще, для такого рода задач в .NET есть интерфейс IEditableObject, но придётся много чего реализовывать с нуля, хотя пример реализации есть прямо на MSDN. Тем не менее, мне очень понравилась библиотека DejaVu, ради которой даже написана целая статья на Хабрахабре. Читайте, влюбляйтесь, пишите.

Есть ещё два примера, но они мне совсем не понравились:




Заключение


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

Удачи в будущем!
Поделиться с друзьями
-->

Комментарии (43)


  1. xmetropol
    27.07.2016 12:03
    +5

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


    1. iCpu
      27.07.2016 12:22

      Это означает, что 5 лет назад этап проектирования был пропущен.


      1. aquamakc
        27.07.2016 13:28
        +8

        Это означает, что 5 лет назад принимающие решение люди таки решили, что этот функционал не нужен.


        1. IvanPonomarev
          28.07.2016 13:12

          Да неее, несогласен, это просто означает, что 5 лет назад 1) скорее всего, никто просто не знал, как делается undo 2) все думали, что «щас не до undo, undo прикрутим как-нибудь потом».

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

          А самая, по-моему, частая фича из числа тех, что часто оставляют на «прикрутим потом», а потом тяжко раскаиваются — распределение прав доступа :-)


          1. iCpu
            28.07.2016 13:25

            При этом даже не нужно было думать, нужен или нет undo, достаточно было просто разделить интерфейс, операции и данные, то есть применить старый добрый MVC. Далеко не все способны сделать это, особенно, если вместе стекаются сторожилы, программировавшие на коболе и новички, изучившие «C++ за 21 день»


          1. aquamakc
            28.07.2016 13:42
            +1

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

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


            1. iCpu
              29.07.2016 06:05

              Согласитесь, подобные хотелки неуместно сравнивать с добавлением базиса для фундаментальных фич современного ПО.
              Тут как-раз такая ситуация, что переносное устройство размером со спичечный коробок по дизайну запитывают от БелАЗовского аккумулятора, а потом, спохватившись, добавили повышающую схему для питания от автомобильного аккумулятора. А подобные внезапные хотелки — обычный форс-мажор, такой же, как и переход госструктур на никсы. Этого нельзя было предугадать,

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


  1. iCpu
    27.07.2016 12:29

    Написать про value-oriented то, что он не требует пересчётов — это лукавство. Всё-таки сохраняется контрольная точка и набор изменений только в сторону redo либо в сторону undo, иначе это несколько автоматизированный Operation-oriented, в котором сложные изменения представляется в виде набора простых команд (в Qt это называется macro).


    1. faserg1
      27.07.2016 12:36

      В Value-oriented может и не быть пересчётов вовсе, если сохранять все значения, даже зависимые. Но я одного не понимаю, при чём тут автоматизированный op-or?


      1. iCpu
        27.07.2016 12:45

        > В Value-oriented может и не быть пересчётов вовсе, если сохранять все значения, даже зависимые
        Но это третий метод же! «когда при наборе лишь одного символа сохранялся весь документ». А если сохранять все зависимые части, то есть от позиции редактирования до конца документа, то восстановление потребует-таки некоторых пересчётов, как минимум, перерисовки.
        Я понимаю, что пример несколько оторванный от реальности, но…


        1. faserg1
          27.07.2016 12:49

          Не настолько зависимые. Одно дело — перерисовка документа. Другое дело — пересчёт всей таблицы (да хоть Excel) после изменения ячейки. Грубо говоря, при смене одной ячейки идёт пересчёт зависимых от неё, и только они сохраняются.
          Перерисовка к Undo/Redo вообще никакого отношения иметь не должна.


          1. iCpu
            27.07.2016 12:58

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


            1. faserg1
              27.07.2016 13:01

              То, что op-or — более гибок, верно. Vl-or более простой вариант, ибо для op-or нужно прописывать реализацию для каждой команды.


  1. RPG18
    27.07.2016 12:30

    А теперь забудьте об этом методе и более не вспоминайте, ибо это уже не Undo/Redo, а бэкапы.

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


    1. faserg1
      27.07.2016 12:34

      Здесь я имел в виду, что сохранять всё и вся без такой необходимости — плохой вариант.


  1. gsaw
    27.07.2016 13:50
    -2

    Есть два паттерна, которые для этих целей подходят. Memento и Command и которые по сути в статье описанны.


    1. faserg1
      27.07.2016 13:51

      Об этом в статье и написано, т.е. эти два паттерна были упомянуты. Но тут более конкретная задача — Undo/Redo.


      1. gsaw
        27.07.2016 16:11

        Извнияюсь, почему то в первый раз не заметил, сейчас пролистал снова и увидел.


        1. faserg1
          27.07.2016 16:14

          Просто добавил информацию об этом на более видное место.


  1. RPG18
    27.07.2016 14:09
    +1

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


    1. faserg1
      27.07.2016 14:11

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


      1. RPG18
        27.07.2016 14:29
        +2

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


        1. faserg1
          28.07.2016 20:11

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


          1. RPG18
            28.07.2016 21:45

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


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


  1. laughman
    27.07.2016 16:40
    +1

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


    1. faserg1
      27.07.2016 16:40
      +1

      Верно. Но про реализацию Undo/Redo между клиентами и серверами в этой статье речь не шла.


  1. Shamov
    27.07.2016 23:32

    Думаю, здесь будет уместно дать отсылку на выступление Шона Пэрента «Inheritance Is The Base Class of Evil». Он там говорит о том, как можно сделать undo за 20 минут. Это, конечно, некоторое преувеличение. Правильнее было бы сказать, что за 20 минут можно вкратце рассказать о том, как ты делал undo в течение многих дней, готовясь к своему выступлению. Но всё равно очень интересно.


    1. iCpu
      28.07.2016 05:40

      ИМХО он не соврал, простой undo действительно делается за 20 минут, 19 из которых — описание команд O-o\переменных V-o в терминах топикстартера. Если не добавлять группы изменений (аля макросы), потокобезопасность, поддержку внешних коммитов и взаимного уничтожения противоречащих друг другу операций (напечатать символ стереть символ напечатать этот же символ), модельки для UI и прочее прочее, то там и делать нечего.


  1. DrReiz
    28.07.2016 11:26

    Удобно комбинировать все три способа. Для частых операций, меняющих небольшую часть «мира» — сделать команды, для внутренне-простых операций поддержать value-state, все остальные операции реализовать через snapshot.
    Плюсы snapshot-а — объем кодирования не зависит от количества операций. Snapshot помогает сделать undo/redo сразу для всего приложения, а затем в своем темпе добавлять undo-redo-команды для часто встречающихся операций.


  1. IvanPonomarev
    28.07.2016 13:02

    Интересный взгляд, и я бы ещё подумал над его корректностью :-) В своей практике я довольно давно решаю эту задачу, есть у меня и текст на Хабре на эту тему, и мне никогда Memento и Command не представлялись как равноправные паттерны для реализации Undo/Redo. Да ещё с выбором «красная или синяя», «одно или другое».

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



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

    Так что может ли возникнуть ситуация, в которой годится что-нибудь ещё, кроме «Command как основное средство + Memento по необходимости» — я не знаю. Не уверен.


  1. xXxVano
    28.07.2016 19:33

    Вы не упомянули основной минус operation-oriented подхода. Он по сути требует реализовать x2 логики. Причём ошибки во 2-й половине кода будут всплывать при использовании только одной фичи (Undo/Redo).

    При реализации паттернов с моделью, удобней реализовывать value-oriented подход на уровне модели, а остальной код покрывать транзакциями, отделяющими разные логические операции.


    1. faserg1
      28.07.2016 19:56

      Это было написано, но в более общем плане. Тем не менее, я добавлю это в статью.


    1. IvanPonomarev
      29.07.2016 09:23

      Извините, но говоря, что «он требует реализовать x2 логики» — Вы просто теоретизируете, или же у Вас есть реальный опыт создания системы с Undo на базе паттерна Command?

      Мой личный опыт показывает, что это подход требует реализовать ну, пожалуй, x1.1 логики. И логика undo/redo настолько взаимоувязана, что никакой проблемы с расширением поля для ошибок нет. Потому что, например, do для вставки — это undo для удаления. В комментарии выше ссылка на мою статью, смотрите, например, как устроен там класс SetCellValue.


      1. faserg1
        29.07.2016 09:27

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


        1. IvanPonomarev
          29.07.2016 12:25

          Не понял, что Вы называете «халатностью»?


      1. iCpu
        29.07.2016 10:24

        Ну, к примеру, отмена удаления объекта с зависимостями вызыает пересоздание всех зависимых объектов и восстановление всех зависимостей. Далеко не всегда это просто сделать. Хотя тут в любом случае затрахаешься.


        1. IvanPonomarev
          29.07.2016 12:25

          Ну ребята, ещё раз скажу — одно дело теоретизировать, а другое — на практике начать делать систему с undo. Тогда многое, казавшееся сложным, на самом деле оказывается существенно проще — и подводные камни возникают, конечно.

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


      1. xXxVano
        29.07.2016 12:26

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

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

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

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

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


        1. IvanPonomarev
          29.07.2016 12:50

          Спасибо за подробный ответ!

          Случаи бывают очень разные. Но автор статьи пишет о 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!
          }
          
          



          1. xXxVano
            29.07.2016 14:42

            В моём случае в стеке 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() );
            	}
            }
            


          1. xXxVano
            29.07.2016 14:53

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

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

            Хотя Undo к некоторым командам при использовании паттерна Сommand нельзя реализовать вовсе. Например, удаление лишнего в дереве/графе объектов, обновление/отмена обновления/повторное обновление данных из сети и т.д.


            1. iCpu
              29.07.2016 16:00

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


              1. xXxVano
                29.07.2016 21:53

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

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