动态

详情 返回 返回

談 C++17 裏的 Visitor 模式 - 动态 详情

Visitor Pattern

訪問者模式是一種行為模式,允許任意的分離的訪問者能夠在管理者控制下訪問所管理的元素。訪問者不能改變對象的定義(但這並不是強制性的,你可以約定為允許改變)。對管理者而言,它不關心究竟有多少訪問者,它只關心一個確定的元素訪問順序(例如對於二叉樹來説,你可以提供中序、前序等多種訪問順序)。

image-20210914091034034

組成

Visitor 模式包含兩個主要的對象:Visitable 對象和 Vistor 對象。此外,作為將被操作的對象,在 Visitor 模式中也包含 Visited 對象。

一個 Visitable 對象,即管理者,可能包含一系列形態各異的元素(Visited),它們可能在 Visitable 中具有複雜的結構關係(但也可以是某種單純的容納關係,如一個簡單的 vector)。Visitable 一般會是一個複雜的容器,負責解釋這些關係,並以一種標準的邏輯遍歷這些元素。當 Visitable 對這些元素進行遍歷時,它會將每個元素提供給 Visitor 令其能夠訪問該 Visited 元素。

這樣一種編程模式就是 Visitor Pattern。

接口

為了能夠觀察每個元素,因此實際上必然會有一個約束:所有的可被觀察的元素具有共同的基類 Visited。

所有的 Visitors 必須派生於 Visitor 才能提供給 Visitable.accept(visitor&) 接口。

namespace hicc::util {

    struct base_visitor {
        virtual ~base_visitor() {}
    };
    struct base_visitable {
        virtual ~base_visitable() {}
    };

    template<typename Visited, typename ReturnType = void>
    class visitor : public base_visitor {
    public:
        using return_t = ReturnType;
        using visited_t = std::unique_ptr<Visited>;
        virtual return_t visit(visited_t const &visited) = 0;
    };

    template<typename Visited, typename ReturnType = void>
    class visitable : public base_visitable {
    public:
        virtual ~visitable() {}
        using return_t = ReturnType;
        using visitor_t = visitor<Visited, return_t>;
        virtual return_t accept(visitor_t &guest) = 0;
    };

} // namespace hicc::util

場景

以一個實例來説,假設我們正在設計一套矢量圖編輯器,在畫布(Canvas)中,可以有很多圖層(Layer),每一圖層包含一定的屬性(例如填充色,透明度),並且可以有多種圖元(Element)。圖元可以是 Point,Line,Rect,Arc 等等。

為了能夠將畫布繪製在屏幕上,我們可以有一個 Screen 設備對象,它實現了 Visitor 接口,因此畫布可以接受 Screen 的訪問,從而將畫布中的圖元繪製到屏幕上。

如果我們提供 Printer 作為觀察者 ,那麼畫布將能夠把圖元打印出來。

如果我們提供 Document 作為觀察者,那麼畫布將能夠把圖元特性序列化到一個磁盤文件中去。

如果今後需要其它的行為,我們可以繼續增加新的觀察者,然後對畫布及其所擁有的圖元進行類似的操作。

特點

  • 如果你需要對一個複雜對象結構 (例如對象樹) 中的所有元素執行某些操作, 可使用訪問者模式。
  • 訪問者模式將非主要的功能從對象管理者身上抽離,所以它也是一種解耦手段。
  • 如果你正在製作一個對象庫的類庫,那麼向外提供一個訪問接口,將會有利於用户無侵入地開發自己的 visitor 來訪問你的類庫——他不必為了自己的一點點事情就給你 issue/pull request。
  • 對於結構層級複雜的情況,要善於使用對象嵌套與遞歸能力,避免反覆編寫相似邏輯。

    請查閲 canva,layer,group 的參考實現,它們通過實現 drawablevistiable<drawable> 的方式完成了嵌套性的自我管理能力,並使得 accept() 能夠遞歸地進入每一個容器中。

實現

我們以矢量圖編輯器的一部分為示例進行實現,採用了前面給出的基礎類模板。

drawable 和 基礎圖元

首先做 drawable/shape 的基本聲明以及基礎圖元:

namespace hicc::dp::visitor::basic {

  using draw_id = std::size_t;

  /** @brief a shape such as a dot, a line, a rectangle, and so on. */
  struct drawable {
    virtual ~drawable() {}
    friend std::ostream &operator<<(std::ostream &os, drawable const *o) {
      return os << '<' << o->type_name() << '#' << o->id() << '>';
    }
    virtual std::string type_name() const = 0;
    draw_id id() const { return _id; }
    void id(draw_id id_) { _id = id_; }

    private:
    draw_id _id;
  };

  #define MAKE_DRAWABLE(T)                                            \
    T(draw_id id_) { id(id_); }                                     \
    T() {}                                                          \
    virtual ~T() {}                                                 \
    std::string type_name() const override {                        \
        return std::string{hicc::debug::type_name<T>()};            \
    }                                                               \
    friend std::ostream &operator<<(std::ostream &os, T const &o) { \
        return os << '<' << o.type_name() << '#' << o.id() << '>';  \
    }

  //@formatter:off
  struct point : public drawable {MAKE_DRAWABLE(point)};
  struct line : public drawable {MAKE_DRAWABLE(line)};
  struct rect : public drawable {MAKE_DRAWABLE(rect)};
  struct ellipse : public drawable {MAKE_DRAWABLE(ellipse)};
  struct arc : public drawable {MAKE_DRAWABLE(arc)};
  struct triangle : public drawable {MAKE_DRAWABLE(triangle)};
  struct star : public drawable {MAKE_DRAWABLE(star)};
  struct polygon : public drawable {MAKE_DRAWABLE(polygon)};
  struct text : public drawable {MAKE_DRAWABLE(text)};
  //@formatter:on
  // note: dot, rect (line, rect, ellipse, arc, text), poly (triangle, star, polygon)
}

為了調試目的,我們重載了 '<<' 流輸出運算符,而且利用宏 MAKE_DRAWABLE 來削減重複性代碼的鍵擊輸入。在 MAKE_DRAWABLE 宏中,我們通過 hicc::debug::type_name<T>() 來獲得類名,並將此作為字符串從 drawable::type_name() 返回。

出於簡化的理由基礎圖元沒有進行層次化,而是平行地派生於 drawable。

複合性圖元和圖層

下面聲明 group 對象,這種對象包含一組圖元。由於我們想要儘可能多的遞歸結構,所以圖層也被認為是一種一組圖元的組合形式:

namespace hicc::dp::visitor::basic {

  struct group : public drawable
    , public hicc::util::visitable<drawable> {
    MAKE_DRAWABLE(group)
      using drawable_t = std::unique_ptr<drawable>;
    using drawables_t = std::unordered_map<draw_id, drawable_t>;
    drawables_t drawables;
    void add(drawable_t &&t) { drawables.emplace(t->id(), std::move(t)); }
    return_t accept(visitor_t &guest) override {
      for (auto const &[did, dr] : drawables) {
        guest.visit(dr);
        UNUSED(did);
      }
    }
  };

  struct layer : public group {
    MAKE_DRAWABLE(layer)
    // more: attrs, ...
  };
}

在 group class 中已經實現了 visitable 接口,它的 accept 能夠接受訪問者的訪問,此時 圖元組 group 會遍歷自己的所有圖元並提供給訪問者。

你還可以基於 group class 創建 compound 圖元類型,它允許將若干圖元組合成一個新的圖元元件,兩者的區別在於,group 一般是 UI 操作中的臨時性對象,而 compound 圖元能夠作為元件庫中的一員供用户挑選和使用。

默認時 guest 會訪問 visited const & 形式的圖元,也就是隻讀方式。

圖層至少具有 group 的全部能力,所以面對訪問者它的做法是相同的。圖層的屬性部分(mask,overlay 等等)被略過了。

畫布 Canvas

畫布包含了若干圖層,所以它同樣應該實現 visitable 接口:

namespace hicc::dp::visitor::basic {

  struct canvas : public hicc::util::visitable<drawable> {
    using layer_t = std::unique_ptr<layer>;
    using layers_t = std::unordered_map<draw_id, layer_t>;
    layers_t layers;
    void add(draw_id id) { layers.emplace(id, std::make_unique<layer>(id)); }
    layer_t &get(draw_id id) { return layers[id]; }
    layer_t &operator[](draw_id id) { return layers[id]; }

    virtual return_t accept(visitor_t &guest) override {
      // hicc_debug("[canva] visiting for: %s", to_string(guest).c_str());
      for (auto const &[lid, ly] : layers) {
        ly->accept(guest);
      }
      return;
    }
  };
}

其中,add 將會以默認參數創建一個新圖層,圖層順序遵循向上疊加方式。get 和 [] 運算符能夠通過正整數下標訪問某一個圖層。但是代碼中沒有包含圖層順序的管理功能,如果有意,你可以添加一個 std::vector<draw_id> 的輔助結構來幫助管理圖層順序。

現在我們來回顧畫布-圖層-圖元體系,accept 接口成功地貫穿了整個體系。

是時候建立訪問者們了

screen 或 printer

這兩者實現了簡單的訪問者接口:

namespace hicc::dp::visitor::basic {
  struct screen : public hicc::util::visitor<drawable> {
    return_t visit(visited_t const &visited) override {
      hicc_debug("[screen][draw] for: %s", to_string(visited.get()).c_str());
    }
    friend std::ostream &operator<<(std::ostream &os, screen const &) {
      return os << "[screen] ";
    }
  };

  struct printer : public hicc::util::visitor<drawable> {
    return_t visit(visited_t const &visited) override {
      hicc_debug("[printer][draw] for: %s", to_string(visited.get()).c_str());
    }
    friend std::ostream &operator<<(std::ostream &os, printer const &) {
      return os << "[printer] ";
    }
  };
}

hicc::to_string 是一個簡易的串流包裝,它做如下的核心邏輯:

template<typename T>
inline std::string to_string(T const &t) {
  std::stringstream ss;
  ss << t;
  return ss.str();
}

test case

測試程序構造了微型的畫布以及幾個圖元,然後示意性地訪問它們:

void test_visitor_basic() {
    using namespace hicc::dp::visitor::basic;

    canvas c;
    static draw_id id = 0, did = 0;
    c.add(++id); // added one graph-layer
    c[1]->add(std::make_unique<line>(++did));
    c[1]->add(std::make_unique<line>(++did));
    c[1]->add(std::make_unique<rect>(++did));

    screen scr;
    c.accept(scr);
}

輸出結果應該類似於這樣:

--- BEGIN OF test_visitor_basic                       ----------------------
09/14/21 00:33:31 [debug]: [screen][draw] for: <hicc::dp::visitor::basic::rect#3>
09/14/21 00:33:31 [debug]: [screen][draw] for: <hicc::dp::visitor::basic::line#2>
09/14/21 00:33:31 [debug]: [screen][draw] for: <hicc::dp::visitor::basic::line#1
--- END OF test_visitor_basic                         ----------------------

It took 2.813.753ms

Epilogue

Visitor 模式有時候能夠被迭代器模式所代替。但是迭代器常常會有一個致命缺陷而影響了其實用性:迭代器本身可能是僵化的、高代價的、效率低下的——除非你做出了最恰當的設計時選擇並實現了最精巧的迭代器。 它們兩者都允許用户無侵入地訪問一個已知的複雜容器的內容。

user avatar Yzi321 头像 u_16231477 头像 starrocks 头像 haoqingwanqiandesigua 头像 muzijun_68c14af5563a2 头像 dl1024 头像 puxiaoke6 头像 houbinbin 头像 secretflow 头像 junyidedalianmao 头像 Reimual 头像 kedixa 头像
点赞 22 用户, 点赞了这篇动态!
点赞

Add a new 评论

Some HTML is okay.