談 C++17 裡的 Memento 模式

hedzr發表於2021-10-17
備忘錄模式:介紹相關概念並實現一個較全面的 Undo Manager 類庫。

Memento Pattern

動機

備忘錄模式也是一種行為設計模式。它在 Ctrl-Z 或者說 Undo/Redo 場所中時最為重要,這裡也是它的最佳應用場所。除此之外,有時候我們也可以稱之為存檔模式,你可以將其泛化到一切備份、存檔、快照的場景裡,例如 macOS 的 Time Machine。

Memento 之所以能成為一種 Pattern,就在於它已經將上述場景進行了抽象和掩蓋。在這裡討論備忘錄模式時一定需要注意到它作為一種設計模式所提供的最強大的能力:不是能夠 Undo/Redo,而是能夠掩蓋細節。

當然要以文字編輯器的 Undo/Redo 場景為例來說明這一點:

Memento 模式會掩蓋編輯器編輯命令的實現細節,例如編輯位置、鍵擊事件、修改的文字內容等等,僅僅只是將它們打包為一條編輯記錄總體地提供給外部。外部使用者無需瞭解所謂的實現細節,它只需要發出 Undo 指令,就能從編輯歷史中抽出並回退一條編輯記錄,從而完成 Undo 動作。

這就是理想中的 Memento 模式應該要達到的效果。

dp-memento

輕量的古典定義

上面提到的字處理器設計是較為豐滿的案例。實際上多數古典的如 GoF 的 Memento 模式的定義是比較輕量級的,它們通常涉及到三個物件:

  1. originator : 創始人通常是指擁有狀態快照的物件,狀態快照由創始人負責進行建立以便於將來從備忘錄中恢復。
  2. memento : 備忘錄儲存狀態快照,一般來說這是個 POJO 物件。
  3. caretaker : 負責人物件負責追蹤多個 memento 物件。

它的關係圖是這樣的:

img

FROM: Here

一個略有調整的 C++ 實現是這樣的:

namespace dp { namespace undo { namespace basic {

  template<typename State>
  class memento_t {
    public:
    ~memento_t() = default;

    void push(State &&s) {
      _saved_states.emplace_back(s);
      dbg_print("  . save memento state : %s", undo_cxx::to_string(s).c_str());
    }
    std::optional<State> pop() {
      std::optional<State> ret;
      if (_saved_states.empty()) {
        return ret;
      }
      ret.emplace(_saved_states.back());
      _saved_states.pop_back();
      dbg_print("  . restore memento state : %s", undo_cxx::to_string(*ret).c_str());
      return ret;
    }
    auto size() const { return _saved_states.size(); }
    bool empty() const { return _saved_states.empty(); }
    bool can_pop() const { return !empty(); }

    private:
    std::list<State> _saved_states;
  };

  template<typename State, typename Memento = memento_t<State>>
  class originator_t {
    public:
    originator_t() = default;
    ~originator_t() = default;

    void set(State &&s) {
      _state = std::move(s);
      dbg_print("originator_t: set state (%s)", undo_cxx::to_string(_state).c_str());
    }
    void save_to_memento() {
      dbg_print("originator_t: save state (%s) to memento", undo_cxx::to_string(_state).c_str());
      _history.push(std::move(_state));
    }
    void restore_from_memento() {
      _state = *_history.pop();
      dbg_print("originator_t: restore state (%s) from memento", undo_cxx::to_string(_state).c_str());
    }

    private:
    State _state;
    Memento _history;
  };

  template<typename State>
  class caretaker {
    public:
    caretaker() = default;
    ~caretaker() = default;
    void run() {
      originator_t<State> o;
      o.set("state1");
      o.set("state2");
      o.save_to_memento();
      o.set("state3");
      o.save_to_memento();
      o.set("state4");

      o.restore_from_memento();
    }
  };

}}} // namespace dp::undo::basic

void test_undo_basic() {
    using namespace dp::undo::basic;
    caretaker<std::string> c;
    c.run();
}

int main() {
    test_undo_basic();
    return 0;
}

這個實現程式碼中對於負責人部分的職責進行了簡化,將相當多的任務交給其他人去完成,目的是在於讓使用者的編碼能夠更簡單。使用者只需要像 caretaker 那樣去操作 originator_t<State> 就能夠完成 memento 模式的運用。

應用場景

簡單的場景可以直接複用上面的 memento_t<State>originator_t<State> 模板,它們雖然簡易,但足以應付一般場景了。

抽象地看待 memento 模式,於一開始我們就提到了一切備份、存檔、快照的場景裡都可以應用 Memento 模式,所以總是不免會用複雜或者說通用的場景需求。這些複雜的需求就不是 memento_t 所能應付的了。

除了那些備份存檔快照場景之外,有時候有的場景或許你併為意識到它們也可以以 memory history 的方式來看待。例如部落格日誌的時間線展示,實際上就是一個 memento list。

實際上,為了給你加深印象,在以類庫開發為已任的生活中我們一般會用 undo_manager 這種東西來表達和實現通用型的 Memento 模式。所以順理成章地,下面我們將嘗試用 Memento 的理論來指導實作一個 undoable 通用型模板類。

Memento 模式通常並不能獨立存在,它多半是作為 Command Pattern 的一個子系統(又或是並立而協作的模組)而存在的。

所以典型的編輯器架構設計裡,總是將文字操作設計為 Command 模式,例如前進一個 word,鍵入幾個字元,移動插入點到某個位置,貼上一段剪貼簿內容,等等,都是由一系列的 Commands 來具體實施的。在此基礎上,Memento 模式才有工作空間,它將能夠利用 EditCommand 來實現編輯狀態的儲存和重播——通過反向播放與重播一條(組)Edit Commands 的方式。

Undo Manager

UndoRedo 模型原本是一種互動性技術,為使用者提供反悔的能力,後來逐漸演變為一種計算模型。通常 Undo 模型被區分為兩種型別:

  1. 線性的
  2. 非線性的

線性 Undo 是以 Stack 的方式實現的,一條新的命令被執行後就會被新增到棧頂。也因此 Undo 系統能夠反序回退已經執行過的命令,且只能是依次反序回退的,所以這種方式實現的 Undo 系統被稱作為線性的。

線上性 Undo 系統中還有嚴格線性模式一說,通常這種提法是指 undo 歷史是有尺寸上限的,正如 CPU 體系中的 Stack 也是有尺寸限制的一個道理。在很多向量繪圖軟體中,Undo 歷史被要求設定為一個初值,例如 1 到 99 之間,如果回退歷史表過大,則最陳舊的 undo 歷史條目會被拋棄。

非線性 Undo

非線性 Undo 系統和線性模型大體上是相似的,也會在某處持有一個已經執行的命令的歷史列表。但不同之處在於,使用者在使用 Undo 系統進行反悔時,他可以挑選歷史列表中的某一命令甚至是某一組命令進行 undo,彷佛他並非執行過這些命令一樣。

Adobe Photoshop 在向操作員提供繪圖能力的同時,就維護了一個幾乎接近於非線性的歷史記錄操作表,並且使用者能夠從該列表中挑選一部分予以撤銷/悔改。但 PS 平時在撤銷某一條操作記錄歷史時,其後的更新的操作記錄將被一併回退,從這個角度來看,它還只能算是線性的,只不過執行批處理 undo 罷了。

如果想要得到非線性撤銷能力,你需要去 PS 的選項中啟用“允許非線性歷史記錄”,這就不再提了。

事實上在互動介面上向使用者提供非線性級別的 Undo/Redo 操作能力的應用,通常並沒有誰能很地支援。一個原因在於從歷史表中抽取一條條目並摘除它,非常容易。但要將這條條目在文件上的效用抽出來摘除,那就可能是完全辦不到。想象一下,如果你對 pos10..20 做了字型加粗,然後對 pos15..30 做了斜體,然後刪除了 pos16..20 的文字,然後對 pos 13..17 做了字型加大,現在要摘掉對 pos15..30 做的斜體操作。請問,你能夠 Undo 斜體操作這一步嗎?顯然這是相當有難度的:可以有很多中解釋方法來做這筆摘除交易,它們或許都符合編輯者的預期。那個“”,是個非常主觀的評價級別。

當然囉,這也未必就是不能夠實現,從邏輯上來說,單純點,不就是倒退三步,放棄一條操作,然後重播(回放)後繼的兩條操作麼,可能執行起來略有點費記憶體之外,也不見得一定會是多麼難。

那麼還得要有另外一個原因在於,很多互動性系統做非線性 Undo 的效果可能是使用者難於腦力預判的,就如我們剛才舉例的撤銷斜體操作一樣,使用者既然無法預測單獨撤銷一條記錄的後果,那麼這個互動功能提供給他就事實上欠缺了意義——還不如讓他逐步回退呢,這樣他將能精確地把握自己的編輯效用的回退效力。

無論是誰最後有道理,都不重要,它們都不會影響到我們做出具備這樣功能的軟體實現。所以實際上有很多類庫能夠提供非線性 Undo 的能力,儘管它們可能並不會被用到某個真實的互動系統上。

此外,關於非線性 Undo 系統的論文也有一大把。充分地證明了論文這種東西,往往都是垃圾——人從出生以來到死去不就是以製造垃圾為己業的麼。人類認為多麼燦爛輝煌的文化,對於自然界和宇宙來說,恐怕真的是毫無意義的——直到未來某一天,人類或許能打破壁壘穿行到更高階別的宇宙,脫離與生俱來的本宇宙的藩籬的桎梏,那時候可能過往的一切才會體現出可能的意義吧。

好,無論宇宙怎麼看,我,仍然認為現在我製造的新的 memento pattern 的 Non-linear Undo Subsystem 是有意義的,而且將會在下面給你做出展示來。:)

進一步的分類

作為一個附加的思考,還可以對分類進一步做出組織。在前文的基本劃分之上,還可以進一步做區分:

  1. 有選擇的 Undo
  2. 可分組的 Undo

大體上,可選的 Undo 是非線性 Undo 的一種增強的操作體現,它允許使用者在回退歷史操作記錄中勾選某些記錄予以撤銷。而可分租的 Undo 是指命令可以被分組,於是使用者可能必需在已經被分組的操作記錄上整體回退,但這會是 Command Pattern 要去負責管理的事情,只是在 Undo 系統上被體現出來而已。

C++ 實現

Undo Manager 實現中,可以有一些典型的實現方案:

  1. 將命令模式的每一條命令指令碼化。這種方式會設立若干的檢查點,而 Undo 時首先是退到某個檢查點,然後將剩餘的指令碼重播一遍,從而完成撤銷某條命令指令碼的功能
  2. 精簡的檢查點。上面的方法,檢查點可能會非常消耗資源,所以有時候需要藉助精緻的命令系統設計來削減檢查點的規模。
  3. 反向播放。這種方式通常只能實現線性回退,其關鍵思想在於反向執行一條命令從而省去建立檢查點的必要。例如最後一步是加粗了 8 個字元,那麼 Undo 時就為這 8 個字元去掉粗體就行了。

但是,對於一個超程式設計實現的通用 Undo 子系統來說,上面提到的方案並不歸屬於 Undo Manager 來管理,它們是劃歸 Command Pattern 去管理的,並且事實上其具體實現由開發者自行完成。Undo Manager 只是負責 states 的儲存、定位和回放等等事務。

主要設計

下面開始真正介紹 undo-cxx 開源庫的實現思路。

undoable_cmd_system_t

首先還是說主體 undoable_cmd_system_t,它需要你提供一個主要的模板引數 State。秉承 memento 模式的基本理論,State 指的是你的 Command 所需要儲存的狀態包,例如對於編輯器軟體來講,Command 是 FontStyleCmd,表示對選擇文字設定字型樣式,而相應的狀態包可能就包含了對字型樣式的最小描述資訊(粗體、斜體等等)。

undoable_cmd_system_t 的宣告大致如下:

template<typename State,
typename Context = context_t<State>,
typename BaseCmdT = base_cmd_t,
template<class S, class B> typename RefCmdT = cmd_t,
typename Cmd = RefCmdT<State, BaseCmdT>>
  class undoable_cmd_system_t;

template<typename State,
typename Context,
typename BaseCmdT,
template<class S, class B> typename RefCmdT,
typename Cmd>
  class undoable_cmd_system_t {
    public:
    ~undoable_cmd_system_t() = default;

    using StateT = State;
    using ContextT = Context;
    using CmdT = Cmd;
    using CmdSP = std::shared_ptr<CmdT>;
    using Memento = typename CmdT::Memento;
    using MementoPtr = typename std::unique_ptr<Memento>;
    // using Container = Stack;
    using Container = std::list<MementoPtr>;
    using Iterator = typename Container::iterator;

    using size_type = typename Container::size_type;
    
    // ...
  };

template<typename State,
typename Context = context_t<State>,
typename BaseCmdT = base_cmd_t,
template<class S, class B> typename RefCmdT = cmd_t,
typename Cmd = RefCmdT<State, BaseCmdT>>
  using MgrT = undoable_cmd_system_t<State, Context, BaseCmdT, RefCmdT, Cmd>;

可以看到,你所提供的 State 將被模板引數 Cmd 所使用:typename Cmd = RefCmdT<State, BaseCmdT>

cmd_t

而 cmd_t 的宣告是這樣的:

template<typename State, typename Base>
class cmd_t : public Base {
  public:
  virtual ~cmd_t() {}

  using Self = cmd_t<State, Base>;
  using CmdSP = std::shared_ptr<Self>;
  using CmdSPC = std::shared_ptr<Self const>;
  using CmdId = std::string_view;
  CmdId id() const { return debug::type_name<Self>(); }

  using ContextT = context_t<State>;
  void execute(CmdSP &sender, ContextT &ctx) { do_execute(sender, ctx); }

  using StateT = State;
  using StateUniPtr = std::unique_ptr<StateT>;
  using Memento = state_t<StateT>;
  using MementoPtr = typename std::unique_ptr<Memento>;
  MementoPtr save_state(CmdSP &sender, ContextT &ctx) { return save_state_impl(sender, ctx); }
  void undo(CmdSP &sender, ContextT &ctx, Memento &memento) { undo_impl(sender, ctx, memento); }
  void redo(CmdSP &sender, ContextT &ctx, Memento &memento) { redo_impl(sender, ctx, memento); }
  virtual bool can_be_memento() const { return true; }

  protected:
  virtual void do_execute(CmdSP &sender, ContextT &ctx) = 0;
  virtual MementoPtr save_state_impl(CmdSP &sender, ContextT &ctx) = 0;
  virtual void undo_impl(CmdSP &sender, ContextT &ctx, Memento &memento) = 0;
  virtual void redo_impl(CmdSP &sender, ContextT &ctx, Memento &memento) = 0;
};

也就是說,State 將被我們包裝之後在 undo 系統內部使用。

而你應該提供的 Command 類則應該從 cmd_t 派生並實現必要的純虛擬函式(do_execute, save_state_impl, undo_impl, redo_impl 等等)。

使用:提供你的命令

按照上面的宣告,我們可以實現一個演示目的的 Command:

namespace word_processor {

  template<typename State>
  class FontStyleCmd : public undo_cxx::cmd_t<State> {
    public:
    ~FontStyleCmd() {}
    FontStyleCmd() {}
    explicit FontStyleCmd(std::string const &default_state_info)
      : _info(default_state_info) {}
    UNDO_CXX_DEFINE_DEFAULT_CMD_TYPES(FontStyleCmd, undo_cxx::cmd_t);

    protected:
    virtual void do_execute(CmdSP &sender, ContextT &) override {
      UNUSED(sender);
      // ... do sth to add/remove font style to/from
      // current selection in current editor ...
      std::cout << "<<" << _info << ">>" << '\n';
    }
    virtual MementoPtr save_state_impl(CmdSP &sender, ContextT &ctx) override {
      return std::make_unique<Memento>(sender, _info);
    }
    virtual void undo_impl(CmdSP &sender, ContextT &, Memento &memento) override {
      memento = _info;
      memento.command(sender);
    }
    virtual void redo_impl(CmdSP &sender, ContextT &, Memento &memento) override {
      memento = _info;
      memento.command(sender);
    }

    private:
    std::string _info{"make italic"};
  };
}

在真實的編輯器中,我們相信你有一個所有編輯器視窗的容器並且能跟蹤到當前具有輸入焦點的編輯器。

基於此,do_execute 應該是對當前編輯器中的選擇文字做字型樣式設定(如粗體),save_state_impl 應該是將選擇文字的元資訊以及 Command 的元資訊打包到 State 中,undo 應該是反向設定字型樣式(如去掉粗體),redo 應該是依據 memento 的 State 資訊再次設定字型樣式(粗體)。

但在本例中,出於演示目的,這些具體細節都被一個 _info 字串所代表了。

儘管 FontStyleCmd 保留了 State 模板引數,但演示程式碼中 State 只會等於 std::string。

使用:提供 UndoCmd 和 RedoCmd

為了定製你的 Undo/Redo 行為,你可以實現自己的 UndoCmd/RedoCmd。它們需要不同於 cmd_t 的特別的基類:

namespace word_processor {
  template<typename State>
  class UndoCmd : public undo_cxx::base_undo_cmd_t<State> {
    public:
    ~UndoCmd() {}
    using undo_cxx::base_undo_cmd_t<State>::base_undo_cmd_t;
    explicit UndoCmd(std::string const &default_state_info)
      : _info(default_state_info) {}
    UNDO_CXX_DEFINE_DEFAULT_CMD_TYPES(UndoCmd, undo_cxx::base_undo_cmd_t);

    protected:
    void do_execute(CmdSP &sender, ContextT &ctx) override {
      std::cout << "<<" << _info << ">>" << '\n';
      Base::do_execute(sender, ctx);
    }
  };

  template<typename State>
  class RedoCmd : public undo_cxx::base_redo_cmd_t<State> {
    public:
    ~RedoCmd() {}
    using undo_cxx::base_redo_cmd_t<State>::base_redo_cmd_t;
    explicit RedoCmd(std::string const &default_state_info)
      : _info(default_state_info) {}
    UNDO_CXX_DEFINE_DEFAULT_CMD_TYPES(RedoCmd, undo_cxx::base_redo_cmd_t);

    protected:
    void do_execute(CmdSP &sender, ContextT &ctx) override {
      std::cout << "<<" << _info << ">>" << '\n';
      Base::do_execute(sender, ctx);
    }
  };
}

注意對於它們來說,相應的基類被限制為 base_(undo/redo)_cmd_t ,並且你必需在 do_execute 實現中包含到基類方法的呼叫,如同這樣:

    void do_execute(CmdSP &sender, ContextT &ctx) override {
      // std::cout << "<<" << _info << ">>" << '\n';
      Base::do_execute(sender, ctx);
    }

基類中有預設的實現,形如這樣:

    template<typename State, typename BaseCmdT,
             template<class S, class B> typename RefCmdT>
    inline void base_redo_cmd_t<State, BaseCmdT, RefCmdT>::
            do_execute(CmdSP &sender, ContextT &ctx) {
        ctx.mgr.redo(sender, Base::_delta);
    }

它實際上具體地呼叫 ctx.mgr,也就是 undoable_cmd_system_t 的 redo() 去完成具體的內務,類似的,undo 方面也有相似的語句。


undo/redo 的特殊之處在於它們的基類有特別的過載函式:

    virtual bool can_be_memento() const override { return false; }

其目的在於不會考慮該命令的 memento 存檔問題。

所以同時也注意 save_state_impl/undo_impl/redo_impl 是不必要的。

actions_controller

我們現在假定字處理器軟體具有一個命令管理器,它同時也是命令動作的 controller,它將會負責在具體的編輯器視窗中執行一條編輯命令:

namespace word_processor {

  namespace fct = undo_cxx::util::factory;

  class actions_controller {
    public:
    using State = std::string;
    using M = undo_cxx::undoable_cmd_system_t<State>;

    using UndoCmdT = UndoCmd<State>;
    using RedoCmdT = RedoCmd<State>;
    using FontStyleCmdT = FontStyleCmd<State>;

    using Factory = fct::factory<M::CmdT, UndoCmdT, RedoCmdT, FontStyleCmdT>;

    actions_controller() {}
    ~actions_controller() {}

    template<typename Cmd, typename... Args>
    void invoke(Args &&...args) {
      auto cmd = Factory::make_shared(undo_cxx::id_name<Cmd>(), args...);
      _undoable_cmd_system.invoke(cmd);
    }

    template<typename... Args>
    void invoke(char const *const cmd_id_name, Args &&...args) {
      auto cmd = Factory::make_shared(cmd_id_name, args...);
      _undoable_cmd_system.invoke(cmd);
    }

    void invoke(typename M::CmdSP &cmd) {
      _undoable_cmd_system.invoke(cmd);
    }

    private:
    M _undoable_cmd_system;
  };

} // namespace word_processor
最後是測試函式

藉助於改進過的工廠模式,controller 可以呼叫編輯命令,注意使用者在發出 undo/redo 時,controller 同樣地通過呼叫 UndoCmd/RedoCmd 的方式來完成相應的業務邏輯。

void test_undo_sys() {
  using namespace word_processor;
  actions_controller controller;

  using FontStyleCmd = actions_controller::FontStyleCmdT;
  using UndoCmd = actions_controller::UndoCmdT;
  using RedoCmd = actions_controller::RedoCmdT;

  // do some stuffs

  controller.invoke<FontStyleCmd>("italic state1");
  controller.invoke<FontStyleCmd>("italic-bold state2");
  controller.invoke<FontStyleCmd>("underline state3");
  controller.invoke<FontStyleCmd>("italic state4");

  // and try to undo or redo

  controller.invoke<UndoCmd>("undo 1");
  controller.invoke<UndoCmd>("undo 2");

  controller.invoke<RedoCmd>("redo 1");

  controller.invoke<UndoCmd>("undo 3");
  controller.invoke<UndoCmd>("undo 4");

  controller.invoke("word_processor::RedoCmd", "redo 2 redo");
}

特性

在 undoable_cmd_system_t 的實現中,包含了基本的 Undo/Redo 能力:

  • 無限制的 Undo/Redo
  • 受限制的:通過 undoable_cmd_system_t::max_size(n) 限制歷史記錄條數

此外,它是全可定製的:

  • 定製你自己的 State 狀態包
  • 定製你的 context_t 擴充套件版本以容納自定義物件引用
  • 如果有必要,你可以定製 base_cmd_t 或 cmd_t 來達到你的特別目的
分組命令

通過基類 class composite_cmd_t 你可以對命令分組,它們在 Undo 歷史記錄中被視為單條記錄,這允許你批量 Undo/Redo。

除了在構造時立即建立組合式命令之外,可以在 composite_cmd_t 的基礎上構造一個 class GroupableCmd,很容易通過這個類提供執行時就地組合數條命令的能力,這樣,你可以獲得更靈活的命令組。

受限制的非線性

通過批量 Undo/Redo 可以實現受限制的非線性 undo 功能。

undoable_cmd_system_t::erase(n = 1) 能夠刪除當前位置的歷史記錄。

你可以認為 undo i - erase j - redo k 是一種受限制的非線性 undo/redo 實現方式,注意這需要你進一步包裝後再運用(通過為 UndoCmd/RedoCmd 增加 _erased_count 成員並執行 ctx.mgr.erase(_erased_count) 的方式)。

更全功能的非線性 undo 可能需要一個更復雜的 tree 狀歷史記錄而不是當前的 list,尚須留待將來實現。

小結

限於篇幅,不能完整介紹 undo-cxx 的能力,所以感興趣的小夥伴直接檢閱 Github 原始碼好了。

後記

這一次的 Undo Manager 實現的尚未盡善盡美,以後再找機會改進吧。

參考:

過段時間再 review,就這麼定了先。

相關文章